# EXR Commercial API

**Version:** 1.0.0  
**Base URL:** `https://cre.exrplatform.com/api/v1`  
**OpenAPI specification:** `https://cre.exrplatform.com/api/v1/openapi.json`

## Source precedence

The OpenAPI document is authoritative for operations, parameters, schemas, scopes, and response contracts. This guide explains how to use that contract; do not infer undocumented fields or behavior.

## Authentication

Send `Authorization: Bearer exr_live_…` on every authenticated request. Missing, invalid, or revoked tokens return `401`. Documentation is public; API data access still requires a token with the required scope.

## Example request

```sh
curl -H "Authorization: Bearer exr_live_..." \
  https://cre.exrplatform.com/api/v1/deals?limit=25
```

## Operations

### GET `/api/v1/health`

Liveness probe. No authentication required.

- **Authentication:** None
- **Idempotency:** Not applicable.
- **Success response:** `object`

### GET `/api/v1/deals`

Returns deals visible to the token's owning user, newest first. Cursor-paginated. Requires the `deals:read` scope. Financial fields are included only when the token also has `deals:financials:read`.

- **Authentication:** Bearer; scope `deals:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of deals to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/deals`

Creates a deal owned by the token's user. Requires the `deals:write` scope. The owner is credited the full 100% commission split; split edits are made via PATCH. Money fields are strings of integer cents. Idempotent via the `Idempotency-Key` header.

- **Authentication:** Bearer; scope `deals:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `DealCreate`
- **Success response:** `object`

### GET `/api/v1/deals/{id}`

Returns a single deal by id. Responds 404 (not 403) when the deal does not exist OR is not visible to the token, so existence is never disclosed. Requires the `deals:read` scope.

- **Authentication:** Bearer; scope `deals:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/deals/{id}`

Partially updates a deal. Requires the `deals:write` scope. Authorizes owner, collaborator, or admin; a missing or not-editable deal responds 404. Only the fields you send are changed. Commission splits are set via the `splits` block, which must describe exactly the current collaborator set and total 100% (re-validated under a row lock).

- **Authentication:** Bearer; scope `deals:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `DealUpdate`
- **Success response:** `object`

### DELETE `/api/v1/deals/{id}`

Deletes a deal. Requires the `deals:write` scope. Owner or admin only; a collaborator or non-member responds 404 to conceal existence.

- **Authentication:** Bearer; scope `deals:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/tenants`

Returns Commercial Tenants visible to the token's owning user, newest first. Cursor-paginated. Requires the `tenants:read` scope. A tenant with no relationship owner is firm-wide; otherwise visibility is the creator, lead owner, or collaborators (admins see all).

- **Authentication:** Bearer; scope `tenants:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of tenants to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/tenants`

Creates a Commercial Tenant owned by the token's user (its creator). Requires the `tenants:write` scope. Emits a `tenant.created` webhook. Send an `Idempotency-Key` to make retries safe.

- **Authentication:** Bearer; scope `tenants:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `TenantWrite`
- **Success response:** `object`

### GET `/api/v1/tenants/{id}`

Returns a single tenant by id. Responds 404 (not 403) when the tenant does not exist OR is not visible to the token. Requires the `tenants:read` scope.

- **Authentication:** Bearer; scope `tenants:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/tenants/{id}`

Partially updates a tenant. Requires the `tenants:write` scope and edit access (creator, lead owner, or admin; firm-wide tenants are editable by any token holder). Responds 404 (not 403) when not found or not editable. Emits a `tenant.updated` webhook.

- **Authentication:** Bearer; scope `tenants:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `TenantWrite`
- **Success response:** `object`

### DELETE `/api/v1/tenants/{id}`

Deletes a tenant. Requires the `tenants:write` scope and delete access (creator, lead owner, or admin — stricter than edit). Responds 404 (not 403) when not found or not deletable. Emits a `tenant.deleted` webhook.

- **Authentication:** Bearer; scope `tenants:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/prospects`

Returns prospects visible to the token's owning user, newest first. Cursor-paginated. Requires the `prospects:read` scope. Visibility is the current owner (`ownerUserId`) plus any prospecting-team member (admins see all).

- **Authentication:** Bearer; scope `prospects:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of prospects to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/prospects`

Creates a prospect owned by the token's user. `propertyAddress` is required. Requires the `prospects:write` scope. Emits a `prospect.created` webhook. Send an `Idempotency-Key` to make retries safe.

- **Authentication:** Bearer; scope `prospects:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `ProspectWrite`
- **Success response:** `object`

### GET `/api/v1/prospects/{id}`

Returns a single prospect by id. Responds 404 (not 403) when it does not exist OR is not visible to the token. Requires the `prospects:read` scope.

- **Authentication:** Bearer; scope `prospects:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/prospects/{id}`

Partially updates a prospect. Requires the `prospects:write` scope and access (owner, team member, or admin). Responds 404 (not 403) when not found or not accessible. Emits a `prospect.updated` webhook.

- **Authentication:** Bearer; scope `prospects:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `ProspectWrite`
- **Success response:** `object`

### DELETE `/api/v1/prospects/{id}`

Hard-deletes a prospect. Requires the `prospects:write` scope; hard delete is admin-only (non-admins should archive instead). Responds 404 when not accessible, 403 when not an admin. Emits a `prospect.deleted` webhook.

- **Authentication:** Bearer; scope `prospects:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/clients`

Returns CRM clients visible to the token's owning user, newest first. Cursor-paginated. Requires the `clients:read` scope. Clients are private: visibility is the owner (`ownerUserId`), the creator (`createdByUserId`), or a collaborator (admins see all).

- **Authentication:** Bearer; scope `clients:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of clients to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/clients`

Creates a CRM client. `name`, `clientType`, and `entityType` are required. The creator is the token's user. `ownerUserId` defaults to the creator when omitted; a non-existent owner → 400 `invalid_reference`. Requires the `clients:write` scope. Emits a `client.created` webhook. Send an `Idempotency-Key` to make retries safe.

- **Authentication:** Bearer; scope `clients:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `ClientWrite`
- **Success response:** `object`

### GET `/api/v1/clients/{id}`

Returns a single CRM client by id. Responds 404 (not 403) when it does not exist OR is not visible to the token. Requires the `clients:read` scope.

- **Authentication:** Bearer; scope `clients:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/clients/{id}`

Partially updates a CRM client. Requires the `clients:write` scope and edit rights (owner, creator, or admin). Collaborators are view-only. Responds 404 (not 403) when not found or not editable. Only the current owner or an admin may change `ownerUserId` (else 403); a non-existent new owner → 400. Emits a `client.updated` webhook.

- **Authentication:** Bearer; scope `clients:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `ClientUpdate`
- **Success response:** `object`

### DELETE `/api/v1/clients/{id}`

Hard-deletes a CRM client. Requires the `clients:write` scope and edit rights (owner, creator, or admin). Responds 404 when not found or not editable. Emits a `client.deleted` webhook.

- **Authentication:** Bearer; scope `clients:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/workspaces`

Returns workspaces (leasing brochures) visible to the token's owning user, newest first. Cursor-paginated. Requires the `workspaces:read` scope. Visibility is the creator plus collaborators (admins see all).

- **Authentication:** Bearer; scope `workspaces:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of workspaces to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/workspaces`

Creates a workspace (leasing brochure) owned by the token's user. `title` and `addressLine1` are required. Requires the `workspaces:write` scope. Emits a `workspace.created` webhook. Send an `Idempotency-Key` to make retries safe.

- **Authentication:** Bearer; scope `workspaces:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `WorkspaceWrite`
- **Success response:** `object`

### GET `/api/v1/workspaces/{id}`

Returns a single workspace (leasing brochure) by id. Responds 404 (not 403) when it does not exist OR is not visible to the token. Requires the `workspaces:read` scope.

- **Authentication:** Bearer; scope `workspaces:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/workspaces/{id}`

Partially updates a workspace (leasing brochure). Requires the `workspaces:write` scope and edit access (owner, collaborator, or admin). Responds 404 (not 403) when not found or not accessible. Emits a `workspace.updated` webhook.

- **Authentication:** Bearer; scope `workspaces:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `WorkspaceWrite`
- **Success response:** `object`

### DELETE `/api/v1/workspaces/{id}`

Deletes a workspace (leasing brochure). Requires the `workspaces:write` scope; deletion is restricted to the owner or an admin (collaborators may edit but not delete). Responds 404 (not 403) when not found or not accessible. Emits a `workspace.deleted` webhook.

- **Authentication:** Bearer; scope `workspaces:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/brokers`

Returns all brokers, newest first. Cursor-paginated. Brokers are firm-wide visible. Requires the `brokers:read` scope.

- **Authentication:** Bearer; scope `brokers:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of brokers to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/brokers`

Creates a broker owned by the token's user. Requires the `brokers:write` scope. `brokerageId` and `brokerSpecialtyId`, when sent, must reference existing rows (else 400 `invalid_reference`).

- **Authentication:** Bearer; scope `brokers:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `BrokerWrite`
- **Success response:** `object`

### GET `/api/v1/brokers/{id}`

Returns a single broker by id. Responds 404 when it does not exist. Requires the `brokers:read` scope.

- **Authentication:** Bearer; scope `brokers:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/brokers/{id}`

Partially updates a broker. Editable by the creator or an admin; otherwise responds 404. Requires the `brokers:write` scope.

- **Authentication:** Bearer; scope `brokers:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `BrokerUpdate`
- **Success response:** `object`

### DELETE `/api/v1/brokers/{id}`

Deletes a broker. Editable by the creator or an admin; otherwise responds 404. Requires the `brokers:write` scope.

- **Authentication:** Bearer; scope `brokers:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`

### GET `/api/v1/brokerages`

Returns all brokerages (firms), newest first. Cursor-paginated. Brokerages are firm-wide visible. Requires the `brokers:read` scope.

- **Authentication:** Bearer; scope `brokers:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of brokerages to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/brokerages`

Creates a brokerage (firm) owned by the token's user. Requires the `brokers:write` scope.

- **Authentication:** Bearer; scope `brokers:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `BrokerageWrite`
- **Success response:** `object`

### GET `/api/v1/brokerages/{id}`

Returns a single brokerage by id. Responds 404 when it does not exist. Requires the `brokers:read` scope.

- **Authentication:** Bearer; scope `brokers:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/brokerages/{id}`

Partially updates a brokerage. Editable by the creator or an admin; otherwise responds 404. Requires the `brokers:write` scope.

- **Authentication:** Bearer; scope `brokers:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `BrokerageUpdate`
- **Success response:** `object`

### DELETE `/api/v1/brokerages/{id}`

Deletes a brokerage. Editable by the creator or an admin; otherwise responds 404. Requires the `brokers:write` scope.

- **Authentication:** Bearer; scope `brokers:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`

### GET `/api/v1/actions`

Returns universal actions (tasks) visible to the token's owning user, newest first. Cursor-paginated. Requires the `actions:read` scope. Visibility follows the in-app rules; linked targets the token cannot access are stripped from each row.

Optional filters compose with pagination and never widen visibility: `status`, `dueBefore`/`dueAfter` (ISO 8601; match actions with a due date in that window), and `targetType`/`targetId` (return only actions linked to a matching record). An unknown enum value or malformed date returns `400 invalid_filter`.

- **Authentication:** Bearer; scope `actions:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of actions to return (1–100).
  - `cursor` (query; string)
  - `status` (query; string) — Filter to actions with this status.
  - `dueBefore` (query; string) — Only actions with a due date at or before this instant (ISO 8601). Actions with no due date are excluded.
  - `dueAfter` (query; string) — Only actions with a due date at or after this instant (ISO 8601). Actions with no due date are excluded.
  - `targetType` (query; string) — Only actions linked to a target of this type.
  - `targetId` (query; string) — Only actions linked to the record with this id (combine with targetType to scope to a specific record type).
- **Success response:** `object`

### POST `/api/v1/actions`

Creates an action with one or more linked targets. Requires the `actions:write` scope plus access to every target referenced (prospect access / contact-of-prospect / readable deal, listing, or landlord), else 403. Emits `action.created`.

- **Authentication:** Bearer; scope `actions:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `ActionCreate`
- **Success response:** `object`

### GET `/api/v1/actions/{id}`

Returns a single action by id. Responds 404 (not 403) when it does not exist OR is not visible to the token. Requires the `actions:read` scope.

- **Authentication:** Bearer; scope `actions:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/actions/{id}`

Updates an existing action. Requires the `actions:write` scope and the same edit rights as the in-app app (creator, assignee, owner, or admin). 404 when not visible. Emits `action.updated`.

- **Authentication:** Bearer; scope `actions:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `ActionUpdate`
- **Success response:** `object`

### DELETE `/api/v1/actions/{id}`

Deletes an action. Requires the `actions:write` scope; only the creator or an admin may delete (else 403). 404 when missing. Emits `action.deleted`.

- **Authentication:** Bearer; scope `actions:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/campaigns`

Returns firm-wide marketing campaign email templates, newest first. Cursor-paginated. Requires the `campaigns:read` scope.

- **Authentication:** Bearer; scope `campaigns:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of templates to return (1–100).
  - `cursor` (query; string)
- **Success response:** `object`

### POST `/api/v1/campaigns`

Creates a marketing campaign email template. Requires the `campaigns:write` scope (NOT admin-gated). Emits `campaign.created`. This never sends email — it only stores a reusable template.

- **Authentication:** Bearer; scope `campaigns:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `CampaignTemplateCreate`
- **Success response:** `object`

### GET `/api/v1/campaigns/{id}`

Returns a single campaign template by id. Requires the `campaigns:read` scope. 404 when missing.

- **Authentication:** Bearer; scope `campaigns:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/campaigns/{id}`

Updates a campaign template. Requires the `campaigns:write` scope. 404 when missing. Emits `campaign.updated`.

- **Authentication:** Bearer; scope `campaigns:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `CampaignTemplateUpdate`
- **Success response:** `object`

### DELETE `/api/v1/campaigns/{id}`

Deletes a campaign template. Requires the `campaigns:write` scope. 404 when missing. Emits `campaign.deleted`.

- **Authentication:** Bearer; scope `campaigns:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/campaign-sends`

Returns read-only marketing campaign send history (one row per blast) with engagement rollups, newest first. Cursor-paginated. Requires the `campaigns:read` scope. There is no endpoint to trigger a send.

- **Authentication:** Bearer; scope `campaigns:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of sends to return (1–100).
  - `cursor` (query; string)
- **Success response:** `object`

### GET `/api/v1/campaign-sends/{id}`

Returns a single campaign send by id, including per-recipient delivery rows. Requires the `campaigns:read` scope. 404 when missing.

- **Authentication:** Bearer; scope `campaigns:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### GET `/api/v1/entities`

Returns landlord-directory entities, newest first. Cursor-paginated. The directory is firm-wide, so any token with `entities:read` sees every (non-merged) entity. Aliases are omitted from the list — fetch a single entity to get them.

- **Authentication:** Bearer; scope `entities:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of entities to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/entities`

Creates a new landlord-directory entity from a name (canonical + contact aliases are seeded automatically). Requires the `entities:write` scope. Emits `entity.created`.

- **Authentication:** Bearer; scope `entities:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `EntityCreate`
- **Success response:** `object`

### GET `/api/v1/entities/{id}`

Returns a single entity by id, including its aliases. Responds 404 when it does not exist. Requires the `entities:read` scope.

- **Authentication:** Bearer; scope `entities:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/entities/{id}`

Updates editable fields on an entity. Requires the `entities:write` scope and edit rights (admin, creator, or no recorded creator) — otherwise 404. Emits `entity.updated`.

- **Authentication:** Bearer; scope `entities:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `EntityUpdate`
- **Success response:** `object`

### POST `/api/v1/entities/{id}/aliases`

Adds a manual alias (alternate name) to an entity. Requires `entities:write` and edit rights. Returns the updated entity with its alias list. Emits `entity.updated`.

- **Authentication:** Bearer; scope `entities:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `object`
- **Success response:** `object`

### POST `/api/v1/entities/{id}/aliases/{aliasId}/verify`

Marks an alias as verified. Requires `entities:write` and edit rights. Emits `entity.updated`.

- **Authentication:** Bearer; scope `entities:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `aliasId` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### DELETE `/api/v1/entities/{id}/aliases/{aliasId}`

Removes an alias from an entity. Requires `entities:write` and edit rights. Emits `entity.updated`.

- **Authentication:** Bearer; scope `entities:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `aliasId` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### GET `/api/v1/agreements`

Returns agreements created by the token's owning user (admins see all), newest first. Cursor-paginated. Requires the `agreements:read` scope. Recipients are status-only (no sign tokens, signatures, or IPs).

- **Authentication:** Bearer; scope `agreements:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `limit` (query; integer) — Maximum number of agreements to return (1–100).
  - `cursor` (query; string) — Opaque pagination cursor from a previous response's `nextCursor`.
- **Success response:** `object`

### POST `/api/v1/agreements`

Creates a draft agreement from a template. Field values are seeded from the template and merged with any provided `values`; numeric bounds are enforced. Recipients' `isSigner` is derived server-side from the role. Requires `agreements:write`. Emits `agreement.created`.

- **Authentication:** Bearer; scope `agreements:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `IdempotencyKey`
- **JSON request:** `AgreementCreate`
- **Success response:** `object`

### GET `/api/v1/agreements/{id}`

Returns a single agreement by id with its recipients. Responds 404 when it does not exist OR is not visible to the token. Requires the `agreements:read` scope.

- **Authentication:** Bearer; scope `agreements:read`
- **Idempotency:** Not applicable.
- **Parameters:**
  - `id` (path; string), required
- **Success response:** `object`

### PATCH `/api/v1/agreements/{id}`

Updates a draft agreement (values, recipients, links, cover note, signing/negotiation mode). Requires `agreements:write` and edit rights (admin or creator) — otherwise 404. Returns 409 if the agreement is no longer a draft or is already linked elsewhere. Emits `agreement.updated`.

- **Authentication:** Bearer; scope `agreements:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `AgreementUpdate`
- **Success response:** `object`

### DELETE `/api/v1/agreements/{id}`

Deletes a draft agreement. Requires `agreements:write` and edit rights (admin or creator) — otherwise 404. Returns 409 if the agreement is no longer a draft (sent agreements must be voided in-app). Emits `agreement.deleted`.

- **Authentication:** Bearer; scope `agreements:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### POST `/api/v1/agreements/{id}/send`

Transitions a draft agreement to `sent` and emails its recipients, driving the same in-app state machine and side effects. Requires `agreements:write` and edit rights (admin or creator) — otherwise 404. Returns 409 when the agreement is not a draft, or 400 when it has no signer recipients. Emits `agreement.sent`. Idempotent via the `Idempotency-Key` header.

- **Authentication:** Bearer; scope `agreements:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### POST `/api/v1/agreements/{id}/void`

Voids an agreement (any state except `completed`), reusing the in-app state machine. Requires `agreements:write` and edit rights (admin or creator) — otherwise 404. Returns 409 when the agreement is already completed or already voided. Emits `agreement.voided`. Idempotent via the `Idempotency-Key` header.

- **Authentication:** Bearer; scope `agreements:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **Success response:** `object`

### POST `/api/v1/agreements/{id}/counter-sign`

Applies the EXR counter-signature to a `signed` agreement, transitioning it to `completed` and triggering the fully-executed emails, via the in-app state machine. Counter-signing is **admin only** — a non-admin token that can see the agreement receives 403 (not 404). Requires `agreements:write`. Returns 409 when the agreement is not awaiting counter-signature, **including when `requiresCounterSign` is `false`** — such agreements complete automatically on the final signature (`agreement.signed` then `agreement.completed` fire back-to-back), so there is nothing to counter-sign. Emits `agreement.completed`. Idempotent via the `Idempotency-Key` header.

- **Authentication:** Bearer; scope `agreements:write`
- **Idempotency:** Optional `Idempotency-Key` supported.
- **Parameters:**
  - `id` (path; string), required
  - `IdempotencyKey`
- **JSON request:** `AgreementCounterSign`
- **Success response:** `object`

## Scopes

Read and write scopes are independent.

- `deals:read` — Read deal records (financial fields require deals:financials:read).
- `deals:financials:read` — Include deal commission, split, lease-term, and payment financials.
- `deals:write` — Create, update, and delete deal records the token's account may edit.
- `tenants:read` — Read Commercial Tenant records visible to the token.
- `tenants:write` — Create, update, and delete Commercial Tenant records the token may edit.
- `prospects:read` — Read prospect records visible to the token.
- `prospects:write` — Create, update, and delete prospect records the token may access.
- `clients:read` — Read CRM client records visible to the token.
- `clients:write` — Create, update, and delete CRM client records the token may access.
- `workspaces:read` — Read workspace (leasing brochure) records visible to the token.
- `workspaces:write` — Create, update, and delete workspace records the token may edit.
- `brokers:read` — Read broker and brokerage records (firm-wide visible).
- `brokers:write` — Create, update, and delete broker and brokerage records the token may edit.
- `actions:read` — Read universal actions (tasks) visible to the token.
- `actions:write` — Create, update, and delete actions the token may edit.
- `campaigns:read` — Read marketing campaign templates and read-only send history.
- `campaigns:write` — Create, update, and delete campaign templates; never trigger a send.
- `entities:read` — Read landlord/entity directory records (firm-wide).
- `entities:write` — Create and update entities and manage their aliases.
- `agreements:read` — Read agreements visible to the creator or an admin.
- `agreements:write` — Create and edit drafts and invoke supported agreement lifecycle transitions.

## Pagination

List endpoints accept `?limit=<1..100>&cursor=<opaque>`. Responses use `{ data, nextCursor, hasMore }`; return `nextCursor` unchanged as the next `cursor` until `hasMore` is false.

## Idempotency

Every `POST`, `PATCH`, and `DELETE` accepts an optional `Idempotency-Key`. Without it, the request has no replay protection. For the same token, method, concrete path, and canonical JSON body, a retry replays the first response, including `4xx` or `5xx`. Matching concurrent calls do not execute the mutation twice. Retry `409 idempotency_request_in_progress` later with the same key; changed reuse returns `409 idempotency_key_conflict`. Pending reservations are never automatically reclaimed.

## Error envelope

`{ "error": { "code", "message", "details?" } }` — `details` can hold machine-readable field validation errors.

## Webhooks

**Supported events (36):**

- `deal.created`
- `deal.updated`
- `deal.deleted`
- `tenant.created`
- `tenant.updated`
- `tenant.deleted`
- `prospect.created`
- `prospect.updated`
- `prospect.deleted`
- `client.created`
- `client.updated`
- `client.deleted`
- `workspace.created`
- `workspace.updated`
- `workspace.deleted`
- `action.created`
- `action.updated`
- `action.deleted`
- `campaign.created`
- `campaign.updated`
- `campaign.deleted`
- `broker.created`
- `broker.updated`
- `broker.deleted`
- `brokerage.created`
- `brokerage.updated`
- `brokerage.deleted`
- `entity.created`
- `entity.updated`
- `agreement.created`
- `agreement.updated`
- `agreement.deleted`
- `agreement.sent`
- `agreement.signed`
- `agreement.completed`
- `agreement.voided`

- Entities emit entity.created and entity.updated only; merge/dedupe is in-app and there is no entity.deleted event.
- Deal, tenant, prospect, workspace, and agreement CRUD events may originate from in-app or public API writes.
- Broker, brokerage, action, campaign, entity, and client events currently originate only from public API writes.
- Agreement sent, signed, completed, and voided events originate in the shared state machine and may be triggered in-app or through the public API.

### Envelope

```json
{ "id": "…", "type": "deal.created", "createdAt": "…", "apiVersion": "v1", "data": { "deal": { "…": "…" } } }
```

Delivery is attempted 4 times (immediate → +30 s → +2 min → +10 min), with a 10-second timeout per attempt. Any HTTP 2xx response acknowledges a delivery.

### Signature verification

```text
signedPayload = <t>.<rawBody>
v1            = HMAC-SHA256(signing_secret, signedPayload)  // hex
header        = X-EXR-Signature: t=<unix-ts>,v1=<hex>
```

Reject stale timestamps and compare the signature with a timing-safe comparison. Never put signing secrets in source control or browser code.

## Request and response schemas

### `WebhookDeletedResource`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | Yes | Identifier of the deleted resource. |

### `Error`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `error` | object | No | — |

### `Deal`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `name` | string | No | — |
| `transactionType` | string | No | — |
| `representation` | string | null | No | Which side EXR represented on this deal. `null` means not yet set. Valid values are scoped by `transactionType`: `lease` → `landlord_rep`, `tenant_rep`, `dual_agency`; `sale` → `seller_rep`, `buyer_rep`, `dual_agency`; `sublease` → `sublessor_rep`, `sublessee_rep`, `dual_agency`; `loan_debt` → `borrower_rep`, `lender_rep`; `valuation_advisory` → any value. `dual_agency` means EXR represented both sides of the transaction. Consumer mapping — switch on the code (never the display label): `landlord_rep` → Landlord, `tenant_rep` → Tenant, `seller_rep` → Seller, `buyer_rep` → Buyer, `sublessor_rep` → Sublessor, `sublessee_rep` → Sublessee, `borrower_rep` → Borrower, `lender_rep` → Lender, `dual_agency` → Both. Treat unknown values defensively. The counterparty's actual name lives in `parties`. This field can change after creation; re-apply the mapping on `deal.updated` webhook events. Allowed: `landlord_rep`, `tenant_rep`, `seller_rep`, `buyer_rep`, `sublessor_rep`, `sublessee_rep`, `borrower_rep`, `lender_rep`, `dual_agency`, `null` |
| `assetClass` | string | null | No | — |
| `status` | string | No | — |
| `property` | object | No | Formatted and structured property address. Structured fields are available without the financial-data scope and are null for legacy or incomplete addresses. |
| `parties` | object | No | — |
| `expectedCloseDate` | string | null | No | — |
| `actualCloseDate` | string | null | No | — |
| `ownerUserId` | string | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |
| `financials` | object | No | Only present when the token has the `deals:financials:read` scope. |

### `DealCreate`

Body for creating a deal. Mirrors the read shape but omits server-managed fields. Money values are strings of integer cents.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | No | Defaults to "Untitled Deal" when omitted. |
| `transactionType` | string | Yes | — |
| `representation` | string | null | No | Which side EXR represented. Must be valid for the chosen `transactionType`: `lease` → `landlord_rep`, `tenant_rep`, `dual_agency`; `sale` → `seller_rep`, `buyer_rep`, `dual_agency`; `sublease` → `sublessor_rep`, `sublessee_rep`, `dual_agency`; `loan_debt` → `borrower_rep`, `lender_rep`; `valuation_advisory` → any value. `null` clears the field. Allowed: `landlord_rep`, `tenant_rep`, `seller_rep`, `buyer_rep`, `sublessor_rep`, `sublessee_rep`, `borrower_rep`, `lender_rep`, `dual_agency`, `null` |
| `assetClass` | string | null | No | — |
| `status` | string | No | Defaults to the first pipeline stage when omitted. |
| `property` | object | Yes | The formatted address is required. Structured fields are optional and nullable; omitted components are stored as null on create. |
| `parties` | object | No | Free-text counterparty names. |
| `expectedCloseDate` | string | null | No | YYYY-MM-DD. |
| `actualCloseDate` | string | null | No | YYYY-MM-DD. Required when status is the closed stage. |
| `financials` | DealFinancialsWrite | No | — |

### `DealUpdate`

Body for a partial deal update. Every field is optional; only the fields you send change. Commission splits are set via the `splits` block.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | No | — |
| `transactionType` | string | No | — |
| `representation` | string | null | No | Which side EXR represented. Must be valid for the deal's `transactionType`: `lease` → `landlord_rep`, `tenant_rep`, `dual_agency`; `sale` → `seller_rep`, `buyer_rep`, `dual_agency`; `sublease` → `sublessor_rep`, `sublessee_rep`, `dual_agency`; `loan_debt` → `borrower_rep`, `lender_rep`; `valuation_advisory` → any value. `null` clears the field. This field can change after creation; consumers should re-apply their mapping on `deal.updated` webhook events. Allowed: `landlord_rep`, `tenant_rep`, `seller_rep`, `buyer_rep`, `sublessor_rep`, `sublessee_rep`, `borrower_rep`, `lender_rep`, `dual_agency`, `null` |
| `assetClass` | string | null | No | — |
| `status` | string | No | — |
| `property` | object | No | When address changes, omitted structured fields are cleared to null so metadata cannot remain attached to the wrong address. If address is omitted or unchanged, omitted structured fields are preserved. |
| `parties` | object | No | — |
| `expectedCloseDate` | string | null | No | YYYY-MM-DD. |
| `actualCloseDate` | string | null | No | YYYY-MM-DD. |
| `financials` | DealFinancialsWrite | No | — |
| `splits` | object | No | Set the deal-team commission splits. Must describe EXACTLY the current collaborator set and total 100% (10000 bps); re-validated under a row lock. A 409 means the team changed since you read it. |

### `DealFinancialsWrite`

Writable financial fields. Money is a string of integer cents.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `potentialCommissionCents` | string | null | No | Overridden by the computed gross commission when complete leaseTerms are supplied on a lease deal. |
| `leaseTerms` | object | No | Lease commission calculator terms. Send null to clear. On PATCH, a non-null object is shallow-merged with the stored terms, so omitted calculator and legacy keys are preserved; explicit null field values overwrite those fields. The server tolerates partial/unknown-field blobs (missing fields fall back to their documented defaults on read/compute), but the shape below is what a fully-populated calculator stores and what reads echo back. |
| `commissionSchedule` | object | No | Agreement-style commission schedule. Send null to clear. |
| `paymentSchedule` | object | No | Commission payment milestones. Send null to clear. |
| `coBrokerageInvoiceMethod` | string | null | No | How the cooperating broker's share is invoiced. `included_in_exr_invoice` — EXR includes the co-brokerage amount on its own client invoice and then pays the cooperating broker. `invoiced_separately` — the cooperating broker invoices the client directly. `null` clears the field (normalized automatically when the effective co-brokerage is removed). **Validation:** on create or update, if the effective `leaseTerms` carries a positive active-mode co-brokerage value (`coBrokerageBps > 0` in percent mode, or `coBrokerageAmountCents > 0` in dollar mode), a non-null method is required — omitting or sending `null` returns `400 invalid_body`. Unrelated partial updates on legacy rows that carry positive co-brokerage but no classified method are exempt from this check. Sending `null` explicitly when the request removes or zeroes the co-brokerage is always accepted and normalizes the stored value to `null`. Allowed: `included_in_exr_invoice`, `invoiced_separately`, `null` |

### `DealLeaseTermsYear`

One lease-year row of the commission calculator. Rates are basis points (10000 = 100%, e.g. 300 = 3%).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `escalationBps` | integer | No | Increase over the PRIOR year's base rent, in basis points (300 = 3%). Year 1 is the starting rent itself, so its escalation is ignored (typically 0). |
| `freeRentMonths` | number | No | Free rent granted in this year, in the blob's `freeRentUnit` (months or weeks; fractional allowed, e.g. 1.5). Only used when `freeRentMode` is `by_year`. |
| `commissionBps` | integer | No | Commission rate for this year, in basis points (300 = 3%). Applied to net rent by default, or to base rent when `commissionBasis` is `base_rent`. Ignored entirely when `commissionCalc` is `fixed`. |

### `DealLeaseTerms`

The deal's lease commission calculator terms — a per-year rent schedule where each year has its own escalation, free rent, and commission rate. Conventions: all money fields are integer CENTS; all rates are BASIS POINTS (10000 = 100%, e.g. 300 = 3%); dates are ISO `YYYY-MM-DD` strings (empty string when not entered). Commission is charged on net rent (base minus free rent) by default, or on base rent when `commissionBasis` is `base_rent`. Reading this field requires the `deals:financials:read` scope.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `sqft` | integer | null | No | Rentable square footage (RSF; total; for retail this is ground floor + basement). Used only to derive PPSF for display. |
| `usableSqft` | integer | null | No | Usable square footage (USF) for Office premises. Informational only; it never affects PPSF or commission calculations. |
| `groundFloorSqft` | integer | null | No | Retail-only breakdown of `sqft`: ground-floor square footage. Display/informational only. |
| `basementSqft` | integer | null | No | Retail-only breakdown of `sqft`: basement square footage. Display/informational only. |
| `startingRentAnnualCents` | integer | null | No | Year-1 base annual rent, in integer cents. Drives every later year via the per-year escalations. |
| `referralFeeBps` | integer | No | Referral fee taken off the gross commission, in basis points (300 = 3%). Used when `referralMode` is `percent`. |
| `referralMode` | string | No | How the referral fee is entered: `percent` (use `referralFeeBps`) or `dollar` (use `referralAmountCents`). Defaults to `percent`. Allowed: `percent`, `dollar` |
| `referralAmountCents` | integer | No | Flat referral amount in integer cents — only used when `referralMode` is `dollar`. |
| `coBrokerageBps` | integer | No | Co-brokerage commission paid to the other broker, in basis points; applied AFTER the referral fee. 0 = no co-broker. |
| `coBrokerageMode` | string | No | How the co-brokerage commission is entered: `percent` (use `coBrokerageBps`) or `dollar` (use `coBrokerageAmountCents`). Defaults to `percent`. Allowed: `percent`, `dollar` |
| `coBrokerageAmountCents` | integer | No | Flat co-brokerage amount in integer cents — only used when `coBrokerageMode` is `dollar`. |
| `freeRentMode` | string | No | How free rent applies: `by_year` (per-year months subtracted from that year's rent) or `amortized` (one total, valued at the year-1 monthly rate, spread evenly across every year). Allowed: `by_year`, `amortized` |
| `freeRentMonthsTotal` | number | No | Total free rent for the whole term, in `freeRentUnit` units — only used when `freeRentMode` is `amortized`. |
| `freeRentUnit` | string | No | Unit ALL free-rent figures are entered in (`years[].freeRentMonths` and `freeRentMonthsTotal`). Weeks convert at 12 months = 52 weeks. Allowed: `months`, `weeks` |
| `commissionBasis` | string | No | What rent figure the per-year commission rate is charged on: `net_rent` (base minus free rent, the default) or `base_rent` (free rent ignored for commission). Only used in `sliding` mode. Allowed: `net_rent`, `base_rent` |
| `commissionCalc` | string | No | How the gross commission is figured: `sliding` (the per-year rate schedule, the default) or `fixed` (a single flat commission via the `fixed*` fields; every year's `commissionBps` is ignored). Allowed: `sliding`, `fixed` |
| `fixedMode` | string | No | In `fixed` mode, how the flat commission is entered: `months` of year-1 monthly base rent (`fixedMonths`), a flat `amount` in cents (`fixedAmountCents`), or one flat `percent` applied to every year's rent (`fixedPercentBps`). Allowed: `months`, `amount`, `percent` |
| `fixedMonths` | number | No | `fixed` + `months`: number of months of the year-1 monthly base rent. |
| `fixedAmountCents` | integer | No | `fixed` + `amount`: the flat commission in integer cents. |
| `fixedPercentBps` | integer | No | `fixed` + `percent`: one flat commission rate in basis points (300 = 3%) applied to EVERY year's rent on the selected basis. |
| `billTo` | string | No | Billing recipient for the commission invoice (free-form). Display/billing only. |
| `billToAddress` | string | No | Billing address. Display/billing only. |
| `billToEmail` | string | No | Billing email. Display/billing only. |
| `termMonths` | integer | null | No | Total lease term in MONTHS when not a whole number of years (e.g. 63 for "5 years 3 months"). Null = whole-year term defined by `years[]` alone. When set, ceil(termMonths/12) must equal `years.length`; the final year row covers only the remaining months and is prorated. |
| `leaseStart` | string | No | Lease commencement date, ISO `YYYY-MM-DD` (empty string when not entered). |
| `leaseEnd` | string | No | Lease expiration date, ISO `YYYY-MM-DD` (empty string when not entered). |
| `years` | array | No | One row per lease year, in order (index 0 = year 1). |

### `DealCommissionSchedule`

Agreement-style commission schedule. A tagged union on `mode`: `sliding` (per-year-bracket rates), `fixed` (one flat commission), or `custom` (free-form text). Percent values here are STRINGS as entered (e.g. "5" = 5%) — unlike the lease-terms blob, which uses basis points. Reading requires the `deals:financials:read` scope.

### `DealCommissionSlidingRow`

One sliding-schedule bracket: a commission rate covering lease years `fromYear`–`toYear` inclusive.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `fromYear` | integer | No | — |
| `toYear` | integer | No | — |
| `percent` | string | No | Rate as an entered string, e.g. "5" = 5%. |

### `DealPaymentSchedule`

When the commission is paid. A tagged union on `mode`: `structured` (ordered milestone rows whose percents should sum to 100) or `custom` (free-form text). Legacy values shaped as `{rows:[…]}` with no `mode` are read as `structured`. Reading requires the `deals:financials:read` scope.

### `DealPaymentScheduleRow`

One payment milestone.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `percent` | string | No | Share of the commission as an entered string, e.g. "50" = 50%. |
| `milestone` | string | No | Milestone label, e.g. "Lease Execution". |
| `expectedDate` | string | No | Optional ISO `YYYY-MM-DD` billing helper — when finance expects the milestone. Never persisted on the "Lease Execution" row (it mirrors the deal's actual close date live). |

### `Tenant`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `name` | string | No | — |
| `domain` | string | null | No | — |
| `businessDescription` | string | null | No | — |
| `businessSinceYear` | integer | null | No | — |
| `activelySearching` | boolean | No | — |
| `createdByUserId` | string | No | — |
| `relationshipOwnerId` | string | null | No | EXR relationship lead-owner. Null means the tenant is firm-wide. |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |

### `TenantWrite`

Writable tenant fields. `name` is required on create. The creator (`userId`) is set from the token and cannot be supplied.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | Yes | — |
| `domain` | string | null | No | — |
| `businessDescription` | string | null | No | — |
| `businessSinceYear` | integer | null | No | — |
| `activelySearching` | boolean | No | — |
| `relationshipOwnerId` | string | null | No | EXR relationship lead-owner. Null means firm-wide. |

### `DeleteResult`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `deleted` | boolean | No | — |

### `Prospect`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `propertyAddress` | string | null | No | — |
| `landlordName` | string | null | No | — |
| `status` | string | No | — |
| `contactMethod` | string | null | No | — |
| `contactEmail` | string | null | No | — |
| `contactNotes` | string | null | No | — |
| `keyQuestions` | string | null | No | — |
| `followUpAction` | string | null | No | — |
| `currentBroker` | string | null | No | — |
| `currentBrokerageId` | string | null | No | — |
| `existingAssetClass` | string | null | No | — |
| `siteClassification` | string | null | No | — |
| `lastContactDate` | string | null | No | — |
| `listingExpirationDate` | string | null | No | — |
| `location` | object | No | — |
| `featured` | boolean | No | — |
| `archivedAt` | string | null | No | — |
| `ownerUserId` | string | No | — |
| `createdByUserId` | string | null | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |

### `ProspectWrite`

Writable prospect fields. `propertyAddress` is required on create. The owner (`userId`) is set from the token; unknown enum values for `status`/`existingAssetClass`/`siteClassification` are silently dropped.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `propertyAddress` | string | Yes | — |
| `landlordName` | string | null | No | — |
| `status` | string | null | No | — |
| `contactMethod` | string | null | No | — |
| `contactEmail` | string | null | No | — |
| `contactNotes` | string | null | No | — |
| `keyQuestions` | string | null | No | — |
| `followUpAction` | string | null | No | — |
| `currentBroker` | string | null | No | — |
| `currentBrokerageId` | string | null | No | — |
| `existingAssetClass` | string | null | No | — |
| `siteClassification` | string | null | No | — |
| `lastContactDate` | string | null | No | — |
| `listingExpirationDate` | string | null | No | — |
| `latitude` | number | null | No | — |
| `longitude` | number | null | No | — |

### `Client`

A CRM client (internally `crm_clients`). Private to its owner, creator, collaborators, and admins.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `name` | string | No | — |
| `clientType` | string | No | Allowed: `seller`, `buyer`, `landlord`, `tenant` |
| `entityType` | string | No | Allowed: `individual`, `company` |
| `assetClasses` | array | No | — |
| `notes` | string | null | No | — |
| `ownerUserId` | string | No | — |
| `createdByUserId` | string | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |

### `ClientWrite`

Writable fields for a client on create (POST). `userId` (creator) is server-managed and ignored if sent. `name`, `clientType`, and `entityType` are required. `ownerUserId` (sent as `clientOwnerId`) defaults to the creator when omitted and must reference an existing user (else 400 `invalid_reference`).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | Yes | — |
| `clientType` | string | Yes | Allowed: `seller`, `buyer`, `landlord`, `tenant` |
| `entityType` | string | Yes | Allowed: `individual`, `company` |
| `assetClasses` | array | No | — |
| `clientOwnerId` | string | No | The owning user. Defaults to the token's user when omitted. Must reference an existing user. |
| `notes` | string | null | No | — |

### `ClientUpdate`

Partial update for a client (PATCH). Same writable fields as ClientWrite, but every field is optional — send only the subset you want to change. Only the current owner or an admin may change `clientOwnerId` (else 403).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | No | — |
| `clientType` | string | No | Allowed: `seller`, `buyer`, `landlord`, `tenant` |
| `entityType` | string | No | Allowed: `individual`, `company` |
| `assetClasses` | array | No | — |
| `clientOwnerId` | string | No | Reassign the owning user. Only the current owner or an admin may change this. Must reference an existing user. |
| `notes` | string | null | No | — |

### `Workspace`

A leasing brochure (internally `leasing_listings`).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `title` | string | No | — |
| `propertyName` | string | null | No | — |
| `status` | string | No | — |
| `assetType` | string | No | — |
| `address` | object | No | — |
| `location` | object | No | — |
| `pricing` | object | No | — |
| `building` | object | No | — |
| `shareToken` | string | null | No | — |
| `description` | string | null | No | — |
| `ownerUserId` | string | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |

### `WorkspaceWrite`

Writable workspace (leasing brochure) fields. `title` and `addressLine1` are required on create; the owner is set from the token. The address key, share tokens, the linked imported-listing id, and the PLUTO-sync timestamp are server-managed and cannot be set through the API. Monetary fields are integer cents.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `title` | string | Yes | — |
| `status` | string | No | — |
| `propertyName` | string | null | No | — |
| `addressLine1` | string | Yes | — |
| `addressLine2` | string | null | No | — |
| `neighborhood` | string | null | No | — |
| `borough` | string | null | No | — |
| `city` | string | null | No | — |
| `state` | string | null | No | — |
| `zip` | string | null | No | — |
| `assetType` | string | No | — |
| `pricingDisplayMode` | string | No | — |
| `askingRentDisplay` | string | null | No | — |
| `askingPpsfCents` | integer | null | No | — |
| `askingRentMonthlyCents` | integer | null | No | — |
| `templateKey` | string | null | No | — |
| `viewerGateEnabled` | boolean | No | — |
| `activeListingUrl` | string | null | No | — |
| `latitude` | string | null | No | — |
| `longitude` | string | null | No | — |
| `description` | string | null | No | — |
| `matterportUrl` | string | null | No | — |
| `videoUrl` | string | null | No | — |
| `yearBuilt` | integer | null | No | — |
| `numFloors` | integer | null | No | — |
| `buildingSf` | integer | null | No | — |
| `lotSf` | integer | null | No | — |
| `zoning` | string | null | No | — |
| `retailSf` | integer | null | No | — |
| `officeSf` | integer | null | No | — |
| `residentialSf` | integer | null | No | — |

### `Broker`

A commercial broker (internally `brokers`).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `name` | string | No | — |
| `workEmail` | string | null | No | — |
| `personalEmail` | string | null | No | — |
| `mobilePhone` | string | null | No | — |
| `workPhone` | string | null | No | — |
| `agentUrl` | string | null | No | — |
| `notes` | string | null | No | — |
| `brokerageId` | string | null | No | — |
| `brokerSpecialtyId` | string | null | No | — |
| `linkedImportedBrokerId` | string | null | No | Read-only reference to a linked global EXR agent record (enrichment only). Managed by the in-app link flow, never the public API. |
| `createdByUserId` | string | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |

### `BrokerWrite`

Writable fields for a broker on create (POST). `userId` and `linkedImportedBrokerId` are server-managed and ignored (stripped) if sent. `name` is required.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | Yes | — |
| `workEmail` | string | null | No | — |
| `personalEmail` | string | null | No | — |
| `mobilePhone` | string | null | No | — |
| `workPhone` | string | null | No | — |
| `agentUrl` | string | null | No | — |
| `notes` | string | null | No | — |
| `brokerageId` | string | null | No | Must reference an existing brokerage (else 400 `invalid_reference`). |
| `brokerSpecialtyId` | string | null | No | Must reference an existing broker specialty (else 400 `invalid_reference`). |

### `BrokerUpdate`

Partial update for a broker (PATCH). Same writable fields as BrokerWrite, but every field is optional — send only the subset you want to change. Server-managed fields are ignored (stripped) if sent.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | No | — |
| `workEmail` | string | null | No | — |
| `personalEmail` | string | null | No | — |
| `mobilePhone` | string | null | No | — |
| `workPhone` | string | null | No | — |
| `agentUrl` | string | null | No | — |
| `notes` | string | null | No | — |
| `brokerageId` | string | null | No | Must reference an existing brokerage (else 400 `invalid_reference`). |
| `brokerSpecialtyId` | string | null | No | Must reference an existing broker specialty (else 400 `invalid_reference`). |

### `Brokerage`

A brokerage firm (internally `brokerages`).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `name` | string | No | — |
| `domain` | string | null | No | — |
| `website` | string | null | No | — |
| `description` | string | null | No | — |
| `linkedImportedBrokerageId` | string | null | No | Read-only reference to a linked global EXR firm record (enrichment only). Managed by the in-app link flow, never the public API. |
| `createdByUserId` | string | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |

### `BrokerageWrite`

Writable fields for a brokerage on create (POST). `userId` and `linkedImportedBrokerageId` are server-managed and ignored (stripped) if sent. `name` is required.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | Yes | — |
| `domain` | string | null | No | — |
| `website` | string | null | No | — |
| `description` | string | null | No | — |

### `BrokerageUpdate`

Partial update for a brokerage (PATCH). Same writable fields as BrokerageWrite, but every field is optional — send only the subset you want to change. Server-managed fields are ignored (stripped) if sent.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | No | — |
| `domain` | string | null | No | — |
| `website` | string | null | No | — |
| `description` | string | null | No | — |

### `ActionTarget`

A record this action is linked to. Targets the token cannot access are stripped from the response.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `targetType` | string | No | One of prospect, contact, landlord, deal, listing. |
| `targetId` | string | No | — |
| `label` | string | null | No | — |

### `Action`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `title` | string | No | — |
| `actionType` | string | No | — |
| `status` | string | No | — |
| `priority` | string | No | — |
| `visibility` | string | No | — |
| `plan` | string | No | — |
| `source` | string | No | — |
| `description` | string | null | No | — |
| `dueAt` | string | null | No | — |
| `snoozeUntil` | string | null | No | — |
| `createdByUserId` | string | No | — |
| `assignedToUserId` | string | null | No | — |
| `completedByUserId` | string | null | No | — |
| `completedAt` | string | null | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |
| `targets` | array | No | — |
| `questions` | array | No | — |

### `ActionCreate`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `title` | string | Yes | — |
| `actionType` | string | No | Defaults to call. |
| `status` | string | No | Defaults to open. |
| `priority` | string | No | Defaults to normal. |
| `visibility` | string | No | Defaults to prospect_team. |
| `plan` | string | No | Defaults to one_time. |
| `description` | string | null | No | — |
| `dueAt` | string | null | No | Due date/time. Preserved in the firm's local timezone (no UTC drift). |
| `snoozeUntil` | string | null | No | — |
| `assignedTo` | string | null | No | User id to assign to; defaults to the token owner. |
| `targets` | array | Yes | At least one target is required; the token must have access to each. |
| `questions` | array | No | — |

### `ActionUpdate`

All fields optional; only provided fields are changed. Setting status to completed records the completer + completion time.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `title` | string | No | — |
| `actionType` | string | No | — |
| `status` | string | No | — |
| `priority` | string | No | — |
| `visibility` | string | No | — |
| `plan` | string | No | — |
| `description` | string | null | No | — |
| `dueAt` | string | null | No | — |
| `snoozeUntil` | string | null | No | — |
| `assignedTo` | string | null | No | — |
| `targetContactId` | string | null | No | Sets/clears the contact target for the action's prospect. |
| `questions` | array | No | — |

### `CampaignTemplate`

A reusable marketing campaign email template. Storing one never sends email.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `name` | string | No | — |
| `subject` | string | No | — |
| `body` | string | No | TipTap/HTML body, returned verbatim. |
| `category` | string | null | No | — |
| `createdByUserId` | string | null | No | — |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |

### `CampaignTemplateCreate`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | Yes | — |
| `subject` | string | Yes | — |
| `body` | string | Yes | — |
| `category` | string | null | No | — |

### `CampaignTemplateUpdate`

All fields optional; only provided fields are changed.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | No | — |
| `subject` | string | No | — |
| `body` | string | No | — |
| `category` | string | null | No | — |

### `CampaignSendRecipient`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `email` | string | No | — |
| `name` | string | null | No | — |
| `sourceType` | string | No | — |
| `sourceId` | string | null | No | — |
| `status` | string | No | — |
| `errorReason` | string | null | No | — |
| `sentAt` | string | null | No | — |
| `deliveredAt` | string | null | No | — |
| `firstOpenedAt` | string | null | No | — |
| `lastOpenedAt` | string | null | No | — |
| `openCount` | integer | No | — |
| `firstClickedAt` | string | null | No | — |
| `lastClickedAt` | string | null | No | — |
| `clickCount` | integer | No | — |
| `bouncedAt` | string | null | No | — |

### `CampaignSend`

Read-only record of a marketing campaign blast with engagement rollups.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `templateId` | string | null | No | — |
| `subject` | string | No | — |
| `body` | string | No | — |
| `sentByUserId` | string | null | No | — |
| `recipientCount` | integer | No | — |
| `status` | string | No | — |
| `scheduledAt` | string | null | No | — |
| `createdAt` | string | null | No | — |
| `rollup` | object | No | Per-send engagement counts, mirroring the in-app campaign detail. delivered/opened/clicked are milestone counts (recipients with a delivered/open/click timestamp); bounced/unsubscribed/spamReport are terminal-status counts; optedOut = unsubscribed + spamReport. |
| `recipients` | array | No | Per-recipient delivery rows; only present on the single-send detail read. |

### `EntityAlias`

An alternate name (spelling/variant) on file for an entity.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `alias` | string | No | — |
| `source` | string | null | No | How the alias was added (e.g. `manual`). |
| `verified` | boolean | No | — |
| `verifiedAt` | string | null | No | — |
| `createdAt` | string | null | No | — |

### `Entity`

A landlord-directory entity (ownership group, owner, manager, brokerage, person, etc.).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `canonicalName` | string | No | — |
| `kind` | string | No | — |
| `primaryPhone` | string | null | No | — |
| `primaryEmail` | string | null | No | — |
| `website` | string | null | No | — |
| `notes` | string | null | No | — |
| `researchStatus` | string | No | — |
| `legalName` | string | null | No | — |
| `mailingAddress` | string | null | No | — |
| `territory` | string | null | No | — |
| `preferredAssetClass` | string | null | No | — |
| `researchSource` | string | null | No | — |
| `lastVerifiedAt` | string | null | No | — |
| `tags` | array | No | — |
| `mergedIntoId` | string | null | No | When set, this entity was merged into the referenced survivor and is hidden from the list. Merge/dedupe is in-app only. |
| `createdByUserId` | string | null | No | Who introduced the entity. Null on legacy rows (which anyone may edit). |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |
| `aliases` | array | No | Present only on the single-entity GET (omitted from the list). |

### `EntityCreate`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | Yes | Display name; the canonical name and seed aliases are derived from it. |
| `kind` | string | No | Defaults to `unknown` when omitted. Allowed: `ownership_group`, `legal_owner`, `management_company`, `brokerage`, `tenant`, `person`, `unknown` |
| `primaryPhone` | string | No | — |
| `primaryEmail` | string | No | — |
| `website` | string | No | — |

### `EntityUpdate`

All fields optional; only provided fields are changed. Send `null` to clear a nullable field.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `canonicalName` | string | No | — |
| `kind` | string | No | Allowed: `ownership_group`, `legal_owner`, `management_company`, `brokerage`, `tenant`, `person`, `unknown` |
| `primaryPhone` | string | null | No | — |
| `primaryEmail` | string | null | No | — |
| `website` | string | null | No | — |
| `notes` | string | null | No | — |
| `researchStatus` | string | No | Allowed: `unresearched`, `needs_review`, `partially_researched`, `verified`, `do_not_contact` |
| `legalName` | string | null | No | — |
| `mailingAddress` | string | null | No | — |
| `territory` | string | null | No | — |
| `preferredAssetClass` | string | null | No | — |
| `researchSource` | string | null | No | — |
| `tags` | array | No | — |

### `AgreementRecipient`

A recipient on an agreement. Status-only — never includes sign tokens, signature images, IPs, or user agents.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `email` | string | No | — |
| `name` | string | null | No | — |
| `order` | integer | No | — |
| `role` | string | No | — |
| `isSigner` | boolean | No | Derived server-side from the role registry. |
| `status` | string | No | — |
| `sentAt` | string | null | No | — |
| `viewedAt` | string | null | No | — |
| `signedAt` | string | null | No | — |
| `createdAt` | string | null | No | — |

### `Agreement`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | No | — |
| `templateId` | string | null | No | — |
| `templateKind` | string | No | — |
| `templateTitle` | string | No | — |
| `status` | string | No | — |
| `signingMode` | string | No | — |
| `negotiationEnabled` | boolean | No | — |
| `requiresCounterSign` | boolean | No | Whether the agreement waits for a separate EXR counter-signature between `signed` and `completed`. When `false`, the final signer's signature completes the agreement directly (fully-executed emails + `agreement.completed` fire from the sign path). Even when `true`, a firm user signing as an explicit signer satisfies the requirement. |
| `values` | object | No | — |
| `hiddenFields` | array | No | — |
| `fields` | array | No | — |
| `coverNote` | string | null | No | — |
| `body` | string | No | Stored (sanitized) template body with {{tokens}} intact. |
| `renderedBody` | string | No | Body with values substituted, re-sanitized through the strict policy used by the PDF/signing path. |
| `linkedDealId` | string | null | No | — |
| `linkedTenantId` | string | null | No | — |
| `linkedListingId` | string | null | No | — |
| `createdByUserId` | string | null | No | — |
| `sentAt` | string | null | No | — |
| `completedAt` | string | null | No | — |
| `voidedAt` | string | null | No | — |
| `counterSignedAt` | string | null | No | Counter-sign status (timestamp) only — never the signature image / IP / UA. |
| `createdAt` | string | null | No | — |
| `updatedAt` | string | null | No | — |
| `recipients` | array | No | — |

### `AgreementRecipientInput`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `email` | string | Yes | — |
| `name` | string | null | No | — |
| `order` | integer | No | — |
| `role` | string | Yes | Recipient role; `isSigner` is derived from it server-side. |

### `AgreementCreate`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `templateId` | string | Yes | — |
| `linkedDealId` | string | null | No | — |
| `linkedTenantId` | string | null | No | — |
| `linkedListingId` | string | null | No | — |
| `coverNote` | string | null | No | — |
| `signingMode` | string | No | Allowed: `parallel`, `sequential` |
| `values` | object | No | Field answers, merged over the template's seeded defaults. |
| `hiddenFields` | array | No | — |
| `recipients` | array | No | — |

### `AgreementUpdate`

Draft-only. All fields optional; only provided fields change. A provided `recipients` array fully replaces the list.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `values` | object | No | — |
| `hiddenFields` | array | No | — |
| `coverNote` | string | null | No | — |
| `signingMode` | string | No | Allowed: `parallel`, `sequential` |
| `negotiationEnabled` | boolean | No | — |
| `requiresCounterSign` | boolean | No | Whether the agreement should wait for an EXR counter-signature after the final signer signs. |
| `linkedDealId` | string | null | No | — |
| `linkedTenantId` | string | null | No | — |
| `linkedListingId` | string | null | No | — |
| `recipients` | array | No | — |

### `AgreementCounterSign`

The EXR counter-signature applied to a fully-signed agreement.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `typedName` | string | Yes | The counter-signer's full typed name. |
| `signatureDataUrl` | string | Yes | A data: URL of the rendered signature image. |
| `agreed` | boolean | Yes | Must be `true` to consent to counter-signing. Allowed: `true` |
