# 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=`. 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 = . v1 = HMAC-SHA256(signing_secret, signedPayload) // hex header = X-EXR-Signature: t=,v1= ``` 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` | --- # EXR Commercial API Webhooks ## 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 = . v1 = HMAC-SHA256(signing_secret, signedPayload) // hex header = X-EXR-Signature: t=,v1= ``` Reject stale timestamps and compare the signature with a timing-safe comparison. Never put signing secrets in source control or browser code. --- # EXR Commercial Lease Commission Calculation Guide This guide is self-contained context for EXR Back Office, Vault, or another financial consumer that needs to read or independently reproduce the commercial lease commission calculation from EXR's public Deal data. It applies **only** to `lease` and `sublease` transactions. It does not define a sales-deal commission engine and should not be used to infer commission for `sale`, `loan_debt`, or `valuation_advisory` deals. Use the public Developer API under `/api/v1`, not the authenticated in-product routes. The public API returns parsed financial objects and stable money representations. The complete, current field shape is available from `GET /api/v1/openapi.json`. ## 1. Access and source-of-truth rules ### Required API access - Read deals through `GET /api/v1/deals` (page with `limit` and the opaque `cursor`) or `GET /api/v1/deals/{id}`. - `deal.created` and `deal.updated` webhooks carry the same full public deal object, including financials when the delivery is authorized for them. - A token needs `deals:financials:read` for the `financials` object. If `financials` is **absent**, that is an authorization limitation, not proof that the deal has no financial data. Do not substitute zeroes. ### Precedence for consuming values 1. For an existing EXR deal, prefer the server-computed `financials.grossCommissionCents`, `financials.gciCents`, `financials.team[].payoutCents`, and `financials.team[].installments` as the operational values. 2. Use `financials.leaseTerms` as the calculator input record and the formulas below for independent parity checks, audits, previews, or a calculation before EXR has returned its computed values. 3. Treat `financials.potentialCommissionCents` as a headline/stored value, not an independent calculator result. EXR replaces it with calculated gross commission when a lease calculation produces a billable result; it can otherwise remain a manually entered estimate. 4. Never use `financials.commissionSchedule` to calculate the lease calculator's gross commission. It is a separate agreement-style description (explained in section 4). ### When values are absent, zero, or incomplete | Condition | Public financial interpretation | | --- | --- | | `financials` absent | The caller lacks the financial-read scope. Stop rather than guessing. | | `leaseTerms` is `null` | The stored calculator field was empty or not readable as JSON. This says nothing about the caller's scope because, with insufficient scope, the entire `financials` object would be absent. | | Transaction is not `lease` or `sublease` | `grossCommissionCents` and `gciCents` are `null` by design; calculator terms are not applicable. | | Lease/sublease has a readable but partial terms object | EXR can still expose calculated fields, often as `"0"`. A zero is not proof that all commercial terms are complete. Inspect the inputs and rates. | | Sliding calculation has no positive starting rent, no rows, or no positive row rate | It has no billable sliding commission. | | Fixed calculation has a zero gross result | It has no billable fixed commission. | The calculator tolerates older and partial records. Missing optional inputs use the defaults in this guide. Unknown or invalid enum values also fall back to those defaults rather than creating a new calculation mode. The formal public contract defines `leaseTerms` as an object or `null`. Historical malformed storage can expose other successfully decoded JSON values even though they are outside that contract. Treat such a value as invalid calculator input; do not reinterpret it as a lease schedule. The server-computed summary remains authoritative. Team payouts are derived from a zero GCI input when no lease calculation is available. Consequently, a team member can have `payoutCents = "0"` even while gross and GCI are `null`. With a valid payment schedule this can include zero-valued labeled installments; otherwise `installments` is empty. Do not interpret that derived zero as evidence that lease terms exist. ## 2. Universal units, precision, and rounding These conventions apply before any formula is evaluated. | Value category | Representation and interpretation | | --- | --- | | Public summary money | Integer **cents serialized as a string**. For example, `"12500000"` is $125,000.00. Preserve it as an integer; divide by 100 only for display. | | `leaseTerms` money inputs | Non-negative integer cents represented as numeric fields: for example, `startingRentAnnualCents` and `fixedAmountCents`. | | Rates | Basis points (bps): 10,000 bps = 100%; 325 bps = 3.25%. Commission, referral, and co-brokerage rates preserve one decimal bps, which is 0.001 percentage-point precision. Escalation is whole bps. | | Square footage | Whole square feet. | | Dates | ISO `YYYY-MM-DD`; an empty string inside lease terms means “not entered.” | | Missing values | `null` means not entered, not zero. Do not turn `null` into zero when aggregating. | All calculations operate in cents. At every `round(...)` in this guide, round to the nearest whole cent; an exact half-cent rounds up. Keep each specified intermediate result as an integer cent value before using it in the next step. This is important: the sum of rounded amortized free-rent slices can differ by a few cents from the one-time total free-rent valuation, and gross commission is the sum of **rounded row commissions**, not one percentage applied to an unrounded aggregate. ## 3. Public deal and financial field dictionary ### Deal context needed to interpret financials | Field | Meaning and use | | --- | --- | | `transactionType` | `lease` or `sublease` activates this guide. Other transaction types do not use this calculator. | | `representation` | Deal-side context such as `landlord_rep`, `tenant_rep`, `sublessor_rep`, or `sublessee_rep`. It does not change the formulas below. | | `status` | Pipeline status. Commission invoice and payout status are operationally meaningful after `deal_closed`; status does not change the calculation. | | `parties.tenant`, `parties.landlord` | Party display context. | | `property.address`, `property.unitNumber` | Property display context. | | `expectedCloseDate`, `actualCloseDate` | Deal dates. `actualCloseDate` can supply the displayed date for a “Lease Execution” payment milestone; it does not change the commission arithmetic. | ### `financials` summary and computed outputs | Field | Unit | Input or output | Meaning | | --- | --- | --- | --- | | `potentialCommissionCents` | string cents or `null` | Stored headline | Manual estimate when no billable lease calculation replaces it; for a calculated lease it is normally the gross commission. | | `grossCommissionCents` | string cents or `null` | Server-computed output | Gross lease commission before referral and co-brokerage. | | `gciCents` | string cents or `null` | Server-computed output | EXR GCI: gross less referral and co-brokerage. It is a residual, not a separate calculator input. | | `ownerSplitBps` | bps | Team allocation input | Owner's deal-team split. | | `team[]` | array | Inputs plus computed outputs | Each member has identity/display fields and `splitBps`; EXR computes the member payout and, when valid, its milestone installments. | | `leaseTerms` | object or `null` | Calculator inputs | The per-year lease rent and commission terms defined in the next sections. | | `commissionSchedule` | object or `null` | Agreement-style data | Separate agreement/document schedule; not a source for lease-calculator math. | | `paymentSchedule` | object or `null` | Payout-timing input | Separate agreement-style milestone schedule used only to divide each team payout into installments. | For every `team[]` member: | Field | Unit | Meaning | | --- | --- | --- | | `userId`, `name`, `email` | text/null | Member identity and display information. | | `splitBps` | bps | Share used to compute that member's GCI payout. Deal-team splits are expected to total 10,000 bps across the team. | | `payoutCents` | string cents or `null` | Rounded member share of GCI. It is derived, not an additional amount to add to GCI. | | `installments[]` | ordered array | Optional derived `{label, amountCents}` breakdown of that member's payout. An empty array means show the member's payout as one lump sum. | ### `leaseTerms`: inputs and defaults `leaseTerms` is the authoritative public input record for the lease calculator. All fields below are calculator inputs unless marked display-only. | Field | Unit / allowed values | Default and active condition | | --- | --- | --- | | `sqft` | whole SF or `null` | Total rentable SF. Values are limited to 100,000,000 SF; empty or zero becomes `null`. Used only for PPSF display, not for rent or commission math. | | `groundFloorSqft`, `basementSqft` | whole SF or `null` | Retail breakdown, display-only. The total `sqft`, not the breakdown, drives PPSF. | | `startingRentAnnualCents` | annual integer cents or `null` | Year-1 full-year base rent. Empty or zero becomes `null`. Required for a normal rent-based schedule. | | `termMonths` | whole months or `null` | `null` means every `years[]` row is a full 12 months. A valid value produces a partial final row when it is not divisible by 12. | | `leaseStart`, `leaseEnd` | ISO date or empty string | Optional display/term-entry dates. They can derive a displayed term but do not override a saved row schedule during arithmetic. | | `years[]` | ordered rows | One row per calculated lease year. It sets row count, escalation, by-year free rent, and sliding rates. | | `freeRentMode` | `by_year` or `amortized` | Defaults to `by_year`. Selects which free-rent inputs are active. | | `freeRentUnit` | `months` or `weeks` | Defaults to `months`. Applies to both the per-row and total free-rent quantities. | | `freeRentMonthsTotal` | non-negative number in the selected unit | Used only in `amortized` mode. Defaults to 0; limited to 600 entered units. | | `commissionBasis` | `net_rent` or `base_rent` | Defaults to `net_rent`. Used by sliding and fixed-percent calculations. | | `commissionCalc` | `sliding` or `fixed` | Defaults to `sliding`. Selects gross-commission method. | | `fixedMode` | `months`, `amount`, or `percent` | Defaults to `months`; used only when `commissionCalc` is `fixed`. | | `fixedMonths` | non-negative number | Used only by fixed-months mode; capped at 600. | | `fixedAmountCents` | non-negative integer cents | Used only by fixed-amount mode. | | `fixedPercentBps` | bps | Used only by fixed-percent mode. | | `referralMode` | `percent` or `dollar` | Defaults to `percent`; selects the active referral input. | | `referralFeeBps` | 0–10,000 bps | Used only in percent referral mode; defaults to 0. | | `referralAmountCents` | non-negative integer cents | Used only in dollar referral mode; defaults to 0. | | `coBrokerageMode` | `percent` or `dollar` | Defaults to `percent`; selects the active co-brokerage input. | | `coBrokerageBps` | 0–10,000 bps | Used only in percent co-brokerage mode; defaults to 0. | | `coBrokerageAmountCents` | non-negative integer cents | Used only in dollar co-brokerage mode; defaults to 0. | | `commissionPaidBy` | `landlord`, `tenant`, `both`, or empty | Defaults to empty. It can assist billing-recipient entry but never changes commission math. | | `billTo`, `billToAddress`, `billToEmail` | text, up to 2,000 characters each | Default empty. Billing display fields only; never affect commission math. | Each `years[]` row is ordered by index: first row is Year 1. All cents input fields are limited to 9,007,199,254,740,991 cents and non-negative values. Whole-cent fields are rounded to whole cents during input normalization. | Row field | Unit | Default / use | | --- | --- | --- | | `escalationBps` | whole bps, 0–1,000,000 | Increase from the prior **full-year** base rent. Year 1's value is ignored. | | `freeRentMonths` | non-negative number in `freeRentUnit` | Used only in `by_year` mode. Fractions are allowed and are capped to the months covered by that row. | | `commissionBps` | bps, up to 1,000,000 | Per-row rate for sliding calculation. Ignored by fixed-months and fixed-amount calculations; replaced by `fixedPercentBps` in fixed-percent calculation. | ## 4. Do not confuse the three financial schedules The public financial object contains three distinct concepts: | Field | Purpose | Units and relationship to the calculator | | --- | --- | --- | | `financials.leaseTerms` | Lease calculator input schedule | The only schedule used to derive rent rows, gross commission, referral, co-brokerage, and GCI. Its money is cents and its rates are bps. | | `financials.commissionSchedule` | Agreement-style commission description | A separate tagged schedule: sliding year brackets, fixed agreement terms, or free-form custom text. Its percentage values are entered **strings** such as `"5"` for 5%, and its fixed dollar text is not a cents field. It does not replace, normalize, or drive `leaseTerms`. | | `financials.paymentSchedule` | When a commission is paid | Structured milestone rows or free-form custom payment text. Structured percentages are entered strings such as `"50"` for 50%. It divides a team member's already-calculated GCI payout; it never changes gross commission or GCI. | For a structured `paymentSchedule`, the rows are ordered and contain a milestone label, percent, and optional expected date. The expected date is a billing helper only. The displayed expected date for “Lease Execution” follows the deal's `actualCloseDate`; it does not affect an installment amount. ## 5. Calculation specification ### 5.1 Normalize the term and build rows Use this sequence: 1. Let `N` be `years[].length`. It is the number of schedule rows. 2. Normalize `termMonths` to a whole number. It is valid only when it is from 1 through 720 and `ceil(termMonths / 12) = N`. 3. If `termMonths` is valid, rows 1 through `N - 1` cover 12 months and row `N` covers `termMonths - 12 × (N - 1)` months. If it is absent or invalid, every row covers 12 months. 4. Lease start/end dates are optional. When both are present, an inclusive lease duration can be derived by treating the day after the end date as the boundary, taking calendar-month difference plus the nearest partial month (day difference divided by 30.44), then limiting the result to 1–720 months. This is a term-entry aid; row count and valid `termMonths` control the arithmetic. For row `i` (Year 1 is `i = 1`), let `A` be `startingRentAnnualCents` and `m_i` be that row's months: - Full-year rate for Year 1: `F_1 = A`. - For each later row: `F_i = round(F_(i-1) × (10,000 + escalationBps_i) / 10,000)`. - The escalation chain always uses full-year rates, even if the last row is partial. - Row base rent: `B_i = F_i` for a 12-month row; otherwise `B_i = round(F_i × m_i / 12)`. - Displayed monthly base rent: `round(F_i / 12)`. This remains the full-year monthly rate even in a partial final row. - Displayed base PPSF: `round(F_i / sqft)` cents per SF when total `sqft` is positive; otherwise it is unavailable. PPSF is informational and never changes the commission calculation. For data entry, an annual dollar rent is converted to integer annual cents. A monthly dollar rent is multiplied by 12 to become annual cents. An annual PPSF entry is multiplied by total SF and then converted to annual cents. Once read from the API, `startingRentAnnualCents` is the authoritative rent input for the formulas above. ### 5.2 Free rent and net rent `freeRentUnit = months` uses the entered number directly. With `freeRentUnit = weeks`, convert entered units to month-equivalents using: `free-rent months = entered weeks × 12 / 52` Equivalently, one week is 3/13 of a month. Do not use four weeks as one month. #### By year This is the default mode. For each row: 1. Convert `years[i].freeRentMonths` from the selected unit to months. 2. Cap it at that row's `m_i` months, then retain the capped quantity for display in the selected unit. 3. Calculate row free-rent value: `R_i = round(F_i / 12 × capped free-rent months)`. 4. Calculate row net rent: `Net_i = max(0, B_i - R_i)`. The cap means free rent cannot exceed the months actually covered by the row. For example, 60 entered weeks in a full-year row caps to 52 weeks, or 12 months. #### Amortized over term Only `freeRentMonthsTotal` is active in this mode. It is capped at 600 entered units. Apply this sequence once: 1. Convert total entered units to month-equivalents. 2. Value total free rent at the Year-1 monthly base rate: `R_total = round(F_1 / 12 × total free-rent months)`. 3. Divide it over the **number of rows**, not the number of lease months: `R_slice = round(R_total / N)`. 4. Put the same `R_slice` on every row, including a partial final row. 5. For each row, calculate `Net_i = max(0, B_i - R_slice)`. The stored/free-rent total reports the entered total quantity, while the schedule reports the rounded dollar slice. Therefore `sum(R_i)` can be a few cents different from `R_total`. The schedule's total net rent uses the rounded row slices. ### 5.3 Select commissionable rent For every row, net rent is displayed regardless of the chosen basis: - `net_rent` (default): `C_i = Net_i`. - `base_rent`: `C_i = B_i`; free rent still appears in net-rent display but is ignored for commission. The basis applies to sliding and fixed-percent calculations. Fixed-months and fixed-amount calculations do not use a row commission basis. ### 5.4 Calculate gross commission Choose exactly one calculation branch. | Mode | Gross-commission rule | | --- | --- | | Sliding (default) | For each row, calculate `Q_i = round(C_i × commissionBps_i / 10,000)`. Gross is `sum(Q_i)`. Round each row first. | | Fixed: months | `Gross = round((F_1 / 12) × fixedMonths)`. `fixedMonths` is non-negative and capped at 600. Per-row commission values are not used. | | Fixed: amount | `Gross = fixedAmountCents`. Per-row commission values are not used. | | Fixed: percent | For each row, set the row rate to `fixedPercentBps` and calculate `Q_i = round(C_i × fixedPercentBps / 10,000)`. Gross is `sum(Q_i)`. This deliberately follows the same per-row rounding as sliding. | In fixed-months and fixed-amount modes, the yearly commission cells have no independent calculation and should be treated as unavailable rather than summed. In fixed-percent mode, they are calculated and sum to gross. ### 5.5 Apply referral, co-brokerage, and EXR GCI All fee rates are limited to 0–10,000 bps. The order is mandatory: 1. **Referral fee** comes from gross. - Percent: `Referral = round(Gross × referralFeeBps / 10,000)`. - Dollar: `Referral = min(referralAmountCents, Gross)`. 2. Set `PostReferral = max(0, Gross - Referral)`. 3. **Co-brokerage** comes from the post-referral amount, never from gross. - Percent: `CoBrokerage = round(PostReferral × coBrokerageBps / 10,000)`. - Dollar: `CoBrokerage = min(coBrokerageAmountCents, PostReferral)`. 4. **EXR GCI** is the residual: `GCI = Gross - Referral - CoBrokerage`. The dollar caps ensure GCI cannot become negative. If no downstream fees are entered, both fees are zero and GCI equals gross. ### 5.6 Split GCI among the deal team and milestones For each deal-team member independently: `MemberPayout = round(GCI × member.splitBps / 10,000)` The member payouts are derived views. Preserve EXR's returned payout values for operational use; an independent replica should apply the same rounding to every member rather than moving a rounding difference between members. Then assess the payment schedule: - Use installment breakdown only for a non-empty, structured milestone schedule whose parsed percentages total within 1 bps of 10,000 bps (99.99%–100.01%). - Custom free text, no rows, or a schedule outside that tolerance produces no installments. The member payout is one lump sum. - For a valid schedule, process milestones in listed order. For every member except the final milestone, calculate `round(MemberPayout × milestonePercentBps / 10,000)`. - The final milestone is `MemberPayout - sum(previous installments)`. It absorbs any rounding remainder, so that member's installments always sum exactly to their payout. ## 6. Required worked parity example: 125-month scenario This example uses the fields visible in the referenced commission scenario. Any value not visible is assumed to remain at its documented default: - 11,460 SF (PPSF is display-only). - $350,000.00 annual starting rent = 35,000,000 cents. - 125-month term, therefore 11 rows: ten 12-month rows and one 5-month row. - Five months of free rent, `amortized` over the term, unit `months`. - Fixed calculation, fixed-percent mode, 3.25% = 325 bps, on `net_rent`. - No escalation, referral, co-brokerage, or other downstream fee. First calculate the free-rent allocation: - Year-1 monthly base is 35,000,000 / 12 = 2,916,666.666... cents. - One-time free-rent value is `round(2,916,666.666... × 5) = 14,583,333` cents = **$145,833.33**. - Each row's amortized slice is `round(14,583,333 / 11) = 1,325,758` cents = **$13,257.58**. | Rows | Months each | Base rent / yr | Free rent / yr | Net rent / yr | Fixed 3.25% commission / row | | --- | ---: | ---: | ---: | ---: | ---: | | Years 1–10 (each) | 12 | $350,000.00 | $13,257.58 | $336,742.42 | $10,944.13 | | Year 11 | 5 | $145,833.33 | $13,257.58 | $132,575.75 | $4,308.71 | The ten full rows each use: `round(33,674,242 cents × 325 / 10,000) = 1,094,413 cents` The partial final row uses: `round(13,257,575 cents × 325 / 10,000) = 430,871 cents` Therefore: - Total base rent = **$3,645,833.33**. - The original one-time free-rent valuation is **$145,833.33**, but 11 rounded schedule slices total **$145,833.38**. - Total schedule net rent is **$3,499,999.95**. - Gross commission is `10 × $10,944.13 + $4,308.71` = **$113,750.01**. - With zero referral and co-brokerage, EXR GCI is also **$113,750.01**. The five-cent free-rent allocation difference and one-cent gross outcome are intentional consequences of EXR's row-by-row rounding rules. Do not replace this with one calculation on aggregate net rent. ## 7. Compact parity examples ### Sliding scale with escalation Starting rent is $120,000.00/year (12,000,000 cents), no free rent, and both Year 1 and Year 2 use 5.00% (500 bps). Year 2 escalates 10.00% (1,000 bps). | Row | Base rent | Commission | | --- | ---: | ---: | | Year 1 | $120,000.00 | `round(12,000,000 × 500 / 10,000)` = $6,000.00 | | Year 2 | $132,000.00 | `round(13,200,000 × 500 / 10,000)` = $6,600.00 | Gross is **$12,600.00**. The escalation is chained from the prior full-year rate before commission is calculated. ### By-year versus amortized free rent Use a two-year $120,000.00/year lease, 10% Year-2 escalation, 5% net commission each year, and one month of total free rent. - **By year:** place the month in the escalated Year 2. Year 1 commission is $6,000.00. Year 2 base is $132,000.00, free rent is $11,000.00, net is $121,000.00, and commission is $6,050.00. Gross = **$12,050.00**. - **Amortized:** value the month at Year-1 monthly rent ($10,000.00) and put $5,000.00 on each row. Year 1 commission is $5,750.00 and Year 2 commission is $6,350.00. Gross = **$12,100.00**. The result differs because by-year free rent uses that year's rent while amortized free rent always uses the Year-1 monthly rate and spreads its dollar value by row. ### Referral then co-brokerage For a $10,000.00 gross commission, referral is 10% and co-brokerage is 50%: 1. Referral = $1,000.00 (10% of gross). 2. Post-referral amount = $9,000.00. 3. Co-brokerage = $4,500.00 (50% of $9,000.00). 4. EXR GCI = $4,500.00. Taking co-brokerage from gross instead would be incorrect. A $12,000 dollar referral would instead cap at the $10,000 gross, leaving $0 for both co-brokerage and GCI. ### Team and milestone rounding Let GCI be 1,000,001 cents ($10,000.01), with a 60% lead and 40% collaborator. Their independently rounded payouts are $6,000.01 and $4,000.00. Use valid, ordered milestones of 33.34%, 33.33%, and 33.33%: | Member | Payout | Milestone 1 | Milestone 2 | Final milestone (remainder) | | --- | ---: | ---: | ---: | ---: | | Lead, 60% | $6,000.01 | $2,000.40 | $1,999.80 | $1,999.81 | | Collaborator, 40% | $4,000.00 | $1,333.60 | $1,333.20 | $1,333.20 | The final installment is not independently percentage-rounded. It is the remainder after prior installments so each member's installment total exactly matches that member's payout. ## 8. Vault handoff semantics - Financial meaning is available only when the deal includes the scoped `financials` object. - Only lease and sublease records use this calculation. - Public `*Cents` summary strings mean integer cents; `leaseTerms` retains the input units documented above. - EXR's computed gross, GCI, team payouts, and installments are the primary operational handoff values. Sections 5.1–5.6 define the independent parity interpretation and its required operation order. - `leaseTerms`, `commissionSchedule`, and `paymentSchedule` remain separate. Agreement text does not supply a calculator rate, and payment milestones do not create a second commission calculation. These semantics give Vault exact EXR operational values and a clear audit basis without prescribing a particular implementation. --- # Using AI Tools with the EXR Commercial API ## Discover the public sources Start with [`/llms.txt`](https://cre.exrplatform.com/llms.txt), a concise index of the explicitly public developer documents. The same index is available at [`/.well-known/llms.txt`](https://cre.exrplatform.com/.well-known/llms.txt). Use [`/llms-full.txt`](https://cre.exrplatform.com/llms-full.txt) only when you need the complete approved Markdown corpus. The canonical machine contract is [OpenAPI 3.1](https://cre.exrplatform.com/api/v1/openapi.json). Human-readable pages and their Markdown alternates are listed in `llms.txt`. ## Source precedence 1. The current OpenAPI document is authoritative for API operations, parameters, schemas, scopes, response codes, and webhook contract fields. 2. The public API reference and webhook guide explain that contract. 3. The approved lease-commission guide explains financial interpretation for lease and sublease deals. 4. If a behavior is absent or conflicts across sources, do not guess. Treat it as undocumented and ask the integration owner to confirm it. ## Safe use - Reading documentation is public. Calling authenticated API operations is not: each call needs a valid bearer token and the required scope. - Keep API tokens and webhook signing secrets out of prompts, source control, client-side code, URLs, logs, and generated artifacts. - Follow cursor pagination, preserve money values as integer-cent strings, and use an `Idempotency-Key` for every write retry. - Verify webhook signatures against the raw request body, reject stale timestamps, and use a timing-safe comparison. - Respect `404` visibility responses. They can mean a record is absent or not visible to the token; do not probe for existence. EXR Commercial does not publish an MCP server. Do not represent these documentation routes as an MCP endpoint.