App API

The programmatic surface of the Horizon API, /api/v2/agent/*, called with an hzn_ API key. Which /api/v2 routes a key can reach, how each request is authorized, and every endpoint with its body, response and status codes.

https://api.horizonplatform.ai/api/v2/agent/* is the part of the Horizon API built for programs that hold an hzn_ API key: boards, tasks, labels, organization members and Sales records.

Your own integrations call it with a key you create in Settings (see API Key Management). Horizon’s own agents call the same routes, through skills that hold an hzn_ key.

Which routes an API key can reach

An Authorization: Bearer hzn_... key reaches three groups of /api/v2 routes, and no others:

RoutesKey acceptedEnforced by
/api/v2/agent/*Yes, and only keys: a signed-in session’s token gets 401Each route checks the key itself, plus one permission per group of routes, as described on this page
/api/v2/forms/*YesThe forms routes check the key’s permission for each entity
Any other /api/v2 path that belongs to a module feature in the key’s permission matrix, for example /boards, /tasks, /erp/crm/contacts and /meetingsYesThe key’s permission for that module and feature
Every other /api/v2 pathNo. 403 PERMISSION_DENIED, “API keys cannot access this endpoint”

The paths closed to keys by that last rule include /settings, /org, /billing, /connections, /agents, /apps, /skills, /modules, /services, /onboarding, /dashboard, /notifications, /operator, /connector-actions and /marketing/sites, apart from four organization-setup routes under /org, /billing and /settings that have module features of their own (see Organization setup with a key). A set of named finance actions is closed as well, among them /erp/finance/disbursement-freeze and /erp/finance/remittance. Because the API keys routes under /settings are closed, a key cannot create, list or revoke keys. An owner or admin creates them in the dashboard, and each key is hzn_ followed by 32 random bytes in base64url.

The refusal says why the path is closed in details.reason, and details.path is the path relative to /api/v2:

json
{
  "error": {
    "code": "PERMISSION_DENIED",
    "message": "API keys cannot access this endpoint",
    "details": {
      "path": "/erp/finance/customers",
      "reason": "retired",
      "movedTo": ["/api/v2/forms/customers"],
      "hint": "This path belonged to the retired legacy finance API. For customers, use /api/v2/forms/customers."
    }
  }
}
details.reasonMeaning
session_onlyThe path serves signed-in users only
retiredThe path belonged to a retired API. details.movedTo lists the paths to use instead
no_routeNo route exists at this path

A path under /api/v2/agent/ that matches none of the endpoints on this page is refused the same way, with details.reason no_route. The exception is paths under /api/v2/agent/users, which are checked against the key’s system.users permission for the request’s method: a key that holds it gets the 404 below instead.

A request that a key may make, or any request from a signed-in session, to a path where no route exists gets 404 in the same envelope:

json
{
  "error": {
    "code": "NOT_FOUND",
    "message": "No API route matches GET /api/v2/tasks",
    "details": {
      "method": "GET",
      "path": "/api/v2/tasks",
      "reason": "no_collection_root",
      "movedTo": [
        "/api/v2/my-tasks",
        "/api/v2/boards/:boardId/tasks"
      ],
      "hint": "…"
    }
  }
}

details.reason is no_collection_root for a path that serves only records below it, retired for a path that belonged to a retired API, and otherwise no_route. movedTo and hint are present when Horizon knows where to go instead. On /api/v2/forms/*, an unknown entity type is a 404 in problem-details form instead, with "detail": "Unknown entity type".

The retired finance endpoints a key can still reach, under /api/v2/erp/finance/bank and /api/v2/erp/finance/periods, answer 501 with code NOT_IMPLEMENTED in the same envelope, and details.movedTo where there is a replacement.

Same key, two enforcement paths

The /api/v2/agent/* routes and the module routes accept the same key and read the same permissions. What they check differs:

/api/v2/agent/*Module routes
Module entitlementNot checked, except on POST /crm/quotes/:id/ordersChecked: a module your organization does not have is refused
Board roleNone. The key’s board access is the whole of its board authorizationThe key acts as its creator, and the creator’s board access applies
Key’s creator removed or deactivatedRefused, see Key checksRefused the same way
Rate limit1,000 requests per 60 seconds per key, see Rate limits100 requests per 60 seconds per key, counted separately

Authentication and authorization

Every /api/v2/agent/* request is first counted against the key’s rate limit, then checked in this order: the key, who the request acts as (see Actor), the key’s creator and the active legal entity, then the one permission the route needs.

Request headers

HeaderRequiredEffect
Authorization: Bearer hzn_...YesThe API key. Anything not starting Bearer hzn_ is refused
Content-Type: application/jsonFor bodiesJSON bodies up to 10 MB. Larger bodies get 413 PAYLOAD_TOO_LARGE
X-Active-Legal-EntityNoA legal-entity id, or * for all entities. Only has an effect when legal-entity separation is on, see below
X-Horizon-Agent-Deployment-IdNoMakes the caller an agent deployment instead of the key’s creator, see Actor
X-Request-IdNoEchoed back, or generated when absent. Recorded on delete audit rows

Key checks

Horizon looks the key up by its hash. It must exist and be active:

Statuserror.codeMessage
401UNAUTHORIZEDMissing or malformed API key
401UNAUTHORIZEDInvalid API key (unknown, or its status is not active)
401UNAUTHORIZEDAPI key expired

The key’s creator must be an active member of the key’s organization:

Statuserror.codeWhen
401KEY_OWNER_NOT_FOUNDThe key’s creator is no longer a member of the organization: they were removed, or they left. Message: API key creator is no longer a member of the organization
403MEMBER_DEACTIVATEDThe key’s creator is deactivated in the organization

These two refusals apply on every /api/v2/agent/* route, and on every other route a key can reach, whatever the organization’s Multi-entity separation setting: Off, Standard, Strict or Inherit.

Removing or deactivating the creator does not revoke the key. It stays active, and it works again from its next request if the creator is reactivated or rejoins the organization. To stop a key for good, revoke or delete it.

Rate limits

Each key can make 1,000 requests to /api/v2/agent/* per 60-second window. Over that, a request is refused with 429:

json
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests" } }
HeaderSent onValue
X-RateLimit-LimitEvery counted response, including the 4291000
X-RateLimit-RemainingThe sameRequests left in the window, never below 0
Retry-AfterThe 429 onlySeconds until the window ends
  • Counted per key. Each key has its own count. Two keys in the same organization do not share one, and calls Horizon’s own agents make with a key count against that key like any other.
  • Counted before the key is checked. A request that is then refused, for example with 401 or 403, still counts.
  • Separate from the other routes. The other /api/v2 routes a key can reach allow 100 requests per 60 seconds per key, counted separately. Requests to one do not use the other’s budget.
  • The window is fixed. It starts with the key’s first counted request and ends 60 seconds later, not on the clock minute. Refused requests count but do not extend it, so waiting Retry-After seconds is always enough.
  • A key has no rate-limit setting of its own, and the limit does not vary by plan. If Horizon cannot count a request, it serves it without the rate-limit headers.

Permission map

The Module permissions you set on a key are stored as a map of "<module>.<feature>": "<actions>", where actions are any of R (Read), W (Write), D (Delete) and X (Execute). Execute applies to workflow actions on /api/v2/forms/*, and no route on this page asks for it. Each group of agent routes requires one entry, and the HTTP method picks the action: GET needs R; POST, PUT and PATCH need W; DELETE needs D.

RoutesPermission
/boards, and its /columns, /sections, /memberscompany.boards
/boards/:boardId/tasks, /taskscompany.tasks
/tasks/:taskId/commentscompany.task-comments and company.tasks W, see below
/boards/:boardId/tabscompany.board-tabs
/labelscompany.labels
/userssystem.users
/crm/contactscrm.contacts
/crm/companiescrm.companies
/crm/dealscrm.deals
/crm/quotescrm.quotes

A missing action returns 403 PERMISSION_DENIED with the message API key lacks <action> permission for <module>.<feature> and details of { module, feature, action }.

A key with Full access (includes future features) stores the map {"*": "RWDX"} and no other entry. The * entry is used only when no entry names the feature, so it covers every feature, including features added after the key was created, and it holds every permission in the table above. It never grants an action a feature does not offer to keys, and it does not open the paths closed to keys.

On the module routes and /api/v2/forms/*, some features cap the actions any key can hold, whatever the map says. See Actions no key can hold. A request for a capped action gets the same 403, but the message goes on to say No API key can be granted <action> here: <reason>., and details adds capped: true, offered, the actions keys can hold there, and hint, which names what to use instead where there is something. None of the permissions in the table above is capped.

Caution

Commenting needs company.tasks write as well as company.task-comments write. A comment request is checked against the task routes’ permission before it reaches the comment route, so a key with only company.task-comments gets 403 naming company.tasks.

Board scope

A key’s Board access sets which boards it reaches:

Board accessStored asBoards the key reaches
All boardsallEvery board in the organization, private boards included
Only selected boardsselectedThe boards stored on the key
Only boards this key’s owner has been added tomemberThe boards on which the key’s creator is a user member, read on every request

A board or task outside the key’s scope returns 404 NOT_FOUND (Board not found or Task not found), never 403. An out-of-scope board cannot be told apart from one that does not exist. GET /boards lists only boards in scope. Labels and /users are organization-wide under every mode.

There is no board-role check on this surface. A key with company.boards D can delete any board in its scope.

Unscoped callers only

Seventeen routes, marked “unscoped only” in the endpoint table, refuse a key whose creator has a dimension grant that limits them to specific dimension values.

The refusal is a 403 in RFC 7807 form, not the usual error envelope:

json
{
  "type": "about:blank",
  "title": "Forbidden",
  "status": 403,
  "detail": "Your access is limited to specific dimension values. Sales covers all values and cannot be filtered."
}

Each agent route resolves the active legal entity from the key creator’s membership. Whether a caller can observe any of it depends on the organization’s Multi-entity separation setting: Off, Standard or Strict, or Inherit, which follows Horizon’s platform default.

  • Off: nothing narrows, X-Active-Legal-Entity has no effect, and no 403 comes from this step. The creator checks under Key checks still apply.
  • Standard or Strict: list and read routes for boards, tasks, contacts, companies, deals and quotes return only records visible under the active entity, except that boards the key’s creator is a member of stay visible. A record outside it returns 404.
  • X-Active-Legal-Entity naming an entity the creator cannot access returns 403 ENTITY_ACCESS_DENIED, as does * under Strict unless the creator has all-entities access or is an owner or admin.
  • PUT and DELETE on a company the active entity does not own return 403 ENTITY_EDIT_DENIED. New boards and companies are stamped with the active entity.

Actor and write attribution

Who a request acts as:

  • No X-Horizon-Agent-Deployment-Id: the actor is the key’s creator.
  • With the header: the actor is that agent deployment, but only if an active skill on the deployment holds a horizon-api connection whose stored key is this key. Otherwise 403 AGENT_DEPLOYMENT_NOT_VERIFIED. The deployment must also be active: one that is paused, configuring, error or removed gets 403 AGENT_DEPLOYMENT_NOT_ACTIVE. The request is never downgraded to the creator.

Writes are attributed as follows:

  • createdBy, updatedBy, completedBy and stage-history changedBy are the key creator’s Clerk user id, whichever actor applies.
  • A comment’s author is the creator, or the agent deployment when the actor is an agent.
  • When the actor is an agent, reading or updating a board, adding a column or section, creating or updating a task or subtask, and commenting add that deployment to the board as an editor member, for attribution.
  • Deleting a contact, company, deal or quote records an audit entry in the same transaction: source AGENT, the key’s creator, the deployment when the actor is an agent, the request id, and a snapshot of the deleted record.
Note

Horizon’s own skills do not send X-Horizon-Agent-Deployment-Id yet. A call a Horizon agent makes with a key therefore acts as the key’s creator and is attributed to them.

Endpoints

All paths are relative to https://api.horizonplatform.ai/api/v2/agent.

MethodPathPermissionUnscoped onlyPurpose
GET/boardscompany.boards RList boards in scope
POST/boardscompany.boards WCreate a board
GET/boards/:boardIdcompany.boards RGet a board with columns, sections, tabs, members
PUT/boards/:boardIdcompany.boards WUpdate a board
DELETE/boards/:boardIdcompany.boards DDelete a board
POST/boards/:boardId/columnscompany.boards WAdd a column
PUT/boards/:boardId/columns/reordercompany.boards WReorder columns
PUT/boards/:boardId/columns/:columnIdcompany.boards WUpdate a column
DELETE/boards/:boardId/columns/:columnIdcompany.boards DDelete a column
POST/boards/:boardId/sectionscompany.boards WAdd a section
PUT/boards/:boardId/sections/reordercompany.boards WReorder sections
PUT/boards/:boardId/sections/:sectionIdcompany.boards WUpdate a section
DELETE/boards/:boardId/sections/:sectionIdcompany.boards DDelete a section
GET/boards/:boardId/memberscompany.boards RList board members
POST/boards/:boardId/memberscompany.boards WAdd a board member
PUT/boards/:boardId/members/:memberIdcompany.boards WChange a member’s role
DELETE/boards/:boardId/members/:memberIdcompany.boards DRemove a board member
GET/boards/:boardId/tabscompany.board-tabs RList saved views
POST/boards/:boardId/tabscompany.board-tabs WCreate a saved view
PUT/boards/:boardId/tabs/reordercompany.board-tabs WReorder saved views
PUT/boards/:boardId/tabs/:tabIdcompany.board-tabs WUpdate a saved view
DELETE/boards/:boardId/tabs/:tabIdcompany.board-tabs DDelete a saved view
GET/boards/:boardId/taskscompany.tasks RList a board’s tasks
POST/boards/:boardId/taskscompany.tasks WCreate a task
GET/tasks?q=company.tasks RSearch task titles across boards
GET/tasks/:taskIdcompany.tasks RGet a task
PUT/tasks/:taskIdcompany.tasks WUpdate a task
DELETE/tasks/:taskIdcompany.tasks DDelete a task
PUT/tasks/:taskId/movecompany.tasks WMove a task, within or across boards
POST/tasks/:taskId/subtaskscompany.tasks WCreate a subtask
POST/tasks/:taskId/commentscompany.task-comments W, company.tasks WComment on a task
GET/labelscompany.labels RList task labels
POST/labelscompany.labels WCreate a label
PUT/labels/:labelIdcompany.labels WUpdate a label
DELETE/labels/:labelIdcompany.labels DDelete a label
GET/userssystem.users RList organization members
GET/users/:memberIdsystem.users RGet a member
POST/userssystem.users WAdd an existing Clerk user to the organization
PATCH/users/:memberIdsystem.users WChange a member’s role
DELETE/users/:memberIdsystem.users DRemove a member from the organization
GET/crm/contactscrm.contacts RList contacts
POST/crm/contactscrm.contacts WYesCreate a contact
GET/crm/contacts/:idcrm.contacts RGet a contact
PUT/crm/contacts/:idcrm.contacts WUpdate a contact
POST/crm/contacts/:id/associate-companycrm.contacts WYesLink a contact to a company
DELETE/crm/contacts/:idcrm.contacts DYesDelete a contact
GET/crm/companiescrm.companies RYesList companies
POST/crm/companiescrm.companies WYesCreate a company
GET/crm/companies/:idcrm.companies RYesGet a company
PUT/crm/companies/:idcrm.companies WYesUpdate a company
DELETE/crm/companies/:idcrm.companies DYesDelete a company
GET/crm/dealscrm.deals RList deals
POST/crm/dealscrm.deals WCreate a deal
GET/crm/deals/:idcrm.deals RGet a deal
PUT/crm/deals/:idcrm.deals WUpdate a deal
POST/crm/deals/:id/change-stagecrm.deals WMove a deal to another stage
DELETE/crm/deals/:idcrm.deals DDelete a deal
GET/crm/quotescrm.quotes RYesList quotes
POST/crm/quotescrm.quotes WYesCreate a draft quote
GET/crm/quotes/:idcrm.quotes RYesGet a quote
PUT/crm/quotes/:idcrm.quotes WYesEdit a draft quote
POST/crm/quotes/:id/sendcrm.quotes WYesMark a draft quote sent
POST/crm/quotes/:id/statuscrm.quotes WYesChange a quote’s status
POST/crm/quotes/:id/orderscrm.quotes WYesCreate a sales order from an accepted quote
DELETE/crm/quotes/:idcrm.quotes DYesDelete a draft quote

Responses

Every success wraps its payload as { "data": ... }. Deletes return 200 with a body, never 204. Identifiers are UUIDs, except user identifiers, which are Clerk user ids (user_...).

Errors use this envelope, where details appears only on some errors:

json
{
  "error": {
    "code": "MISSING_FIELDS",
    "message": "firstName and lastName are required"
  }
}

Two refusals use an RFC 7807 body instead: unscoped-only routes and the role check on POST /crm/quotes/:id/orders. Codes this surface raises beyond the ones above:

Statuserror.codeCause
400VALIDATION_ERRORA body failed its schema. details lists { path, message } per issue
400INVALID_JSONThe body is not valid JSON
400INVALID_BODY_ENCODINGThe body is labelled gzip, deflate or br but does not decompress
400MISSING_FIELDS, MISSING_PARAMA required field or query parameter is absent
400INVALID_UUIDAn id field is not a UUID
400INVALID_COMPANY, INVALID_CONTACT, INVALID_DEAL, INVALID_PIPELINE, INVALID_PRODUCT, INVALID_PARENTA referenced id is not in the organization. Message: <field> not found in this org
404NOT_FOUNDNot found, out of the key’s board scope, or hidden by legal-entity separation
409variesA state conflict, listed per resource below

Pagination

Two styles, both newest first (createdAt descending):

EndpointsQueryResponseLimits
/crm/contacts, /crm/companies, /crm/deals, /crm/quoteslimit, cursor{ data, cursor, hasMore }. cursor is the last row’s id, or null on the last pageDefault 25, clamped to 1-100
/boards/:boardId/tasks, /tasks?q=limit, cursor{ data } only. Pass the last task’s id as cursor; a page shorter than limit is the lastDefault 50, at most 200

GET /users returns at most 200 members, with no cursor. The other list routes are unpaginated.

Tasks

Task routes return the task row with no relations.

Create a task

POST /boards/:boardId/tasks returns 201 with the task. It reads only these fields from the body:

FieldTypeRequiredNotes
titlestringYesAt least one character
columnIdstringYesMust be a column on this board, else 400 INVALID_COLUMN
descriptionstring or nullNo
sectionIdstring or nullNo
prioritynone, low, medium, high, urgentNo
assigneeTypeuser, agent, or nullNo
assigneeUserIdstring or nullNoA Clerk user id
assigneeAgentDeploymentIdstring or nullNoMust be a deployment in the organization, else 404 NOT_FOUND
dueDatedate string or nullNo

Two kinds of field are refused rather than ignored, anywhere in a task body:

  • agentQueueStatus, agentQueueOrder, agentConversationId and skipQueueReconciliation return 400 DISPATCH_OWNED_FIELD, even when the value is null. The agent queue owns them.
  • include returns 400 UNSUPPORTED_FIELD. The server chooses the returned shape.

Other unknown fields are dropped. labelIds is dropped on create; set labels with an update.

Note

Assigning a task to an agent here records the assignment and nothing else. The agent is not queued or started, because this surface cannot set the queue fields and never dispatches the agent. Creating the task through POST /api/v2/boards/:boardId/tasks, the route the Horizon app uses, queues it for the agent and can start the agent.

Update, move and delete

PUT /tasks/:taskId is a partial update. It accepts the create fields except that columnId is optional, plus:

FieldTypeNotes
columnIdstring or nullMust be a column on the task’s current board
statusnot_started, in_progress, completedChanging to completed sets completedAt and completedBy
autonomyOverridesuggest_only, supervised, autonomous, or null
labelIdsstring arrayReplaces the task’s labels. [] clears them. Ids from another organization are dropped without an error

Completing a task assigned to an agent, or changing its agent assignee, also sets that agent’s other working tasks in the organization to done.

PUT /tasks/:taskId/move takes columnId, position and targetBoardId, and nothing else:

  • position is clamped to the destination column’s range; omit it to append.
  • With targetBoardId, the destination must be in the key’s scope and the organization (404 Board not found otherwise), and columnId must name one of its columns (400 INVALID_COLUMN). A cross-board move without columnId fails the same way. The task’s section is cleared and its subtasks move with it.

DELETE /tasks/:taskId returns { "data": { "id": "..." } }.

Subtasks

POST /tasks/:taskId/subtasks takes title (required, 400 MISSING_FIELDS), description, assigneeType, assigneeUserId, assigneeAgentDeploymentId and dueDate, and returns 201. The subtask takes its parent’s column. A subtask cannot have subtasks: 400 INVALID_NESTING.

GET /boards/:boardId/tasks accepts limit, cursor and tabId. tabId applies a saved view’s filters; an unknown tabId returns the unfiltered list.

GET /tasks searches task titles, case-insensitively, across the boards the key can reach:

QueryRequiredNotes
qYes400 MISSING_PARAM when absent
boardIdNoRestricts to one board. Out of scope returns 404 Board not found
limit, cursorNoAs in Pagination

Comments

POST /tasks/:taskId/comments takes { "body": "..." }, which must be non-empty, and returns 201 with the comment. It needs two permissions, see Permission map.

Mentions are written inline as @[Display Name](user:<clerkUserId>) or @[Display Name](agent:<agentDeploymentId>). For each mention of a user or agent deployment in the organization, Horizon:

  • adds them to the task’s board as an editor if they are not already a member
  • starts a run of a mentioned agent on the task

Mentions of ids outside the organization stay in the text and have no effect.

Boards

Boards

RouteBodyResult
POST /boardsname (required, 400 MISSING_FIELDS), description, visibility (public or private, default private), goalId (must be in the organization, else 400 INVALID_GOAL), templateColumns (array of { name, color })201. The key’s creator becomes a board admin, and the board gets one saved view named All Tasks. A legalEntityId in the body is ignored
GET /boards{ data: { myBoards, otherBoards, guestBoards } }. myBoards are boards in scope where the key’s creator is a user member; otherBoards are public boards in scope the creator has not joined; guestBoards is always empty
GET /boards/:boardIdThe board with columns, members, goal, a task count, sections and tabs
PUT /boards/:boardIdname, description, visibility, goalId (null clears)The updated board
DELETE /boards/:boardId{ data: { id } }

GET /boards shows the creator’s view: an all-mode key does not list private boards its creator has not joined, but it can still fetch and change them by id.

The board body is not schema-validated on this surface. Unknown fields are ignored.

Columns, sections and members

RouteBody
POST /boards/:boardId/columnsname, color, position. Without position the column is appended
PUT /boards/:boardId/columns/:columnIdname, color, position
PUT /boards/:boardId/columns/reorderorderedIds: column ids in the new order
POST /boards/:boardId/sectionsname, position
PUT /boards/:boardId/sections/:sectionIdname, position
PUT /boards/:boardId/sections/reorderorderedIds
POST /boards/:boardId/membersmemberType (user or agent), then userId (a Clerk user id of an organization member or guest) or agentDeploymentId (a deployment in the organization), and role (viewer, commenter, editor, admin; default viewer). A principal not in the organization returns 404
PUT /boards/:boardId/members/:memberIdrole only

Deletes return { data: { id } }.

Saved views (tabs)

RouteBodyResult
POST /boards/:boardId/tabsname (required), viewMode (list; any other value is stored as board), filters (validated, 400 INVALID_FILTERS)201 with the view
PUT /boards/:boardId/tabs/:tabIdname, viewMode, filtersThe view
PUT /boards/:boardId/tabs/reordertabIds, every one on this board, else 400 INVALID_INPUT{ data: { reordered: true } }
DELETE /boards/:boardId/tabs/:tabId{ data: { id, deleted: true } }

Labels

Labels belong to the organization, not to a board, so board scope does not apply. POST /labels takes name (required) and color (default ""); PUT /labels/:labelId takes either. A label outside the organization returns 404 Label not found. GET /labels is sorted by name.

Sales

Create and update bodies are validated by the same schemas as the Sales module’s own /api/v2/erp/crm/* routes, which strip unknown fields. Each route then writes only the fields listed below. Other schema fields, such as a contact’s department or a company’s website, pass validation and are not stored by these routes.

ownerId is a Clerk user id and is not checked against the organization’s members.

customFields behaves differently by route. On PUT for contacts and companies it merges into the stored object: absent leaves it alone, null clears it, an object replaces the keys it names and deletes keys set to null, and anything else returns 400 INVALID_CUSTOM_FIELDS. On deal PUT, and on every create, the value sent replaces what is stored.

Contacts

RouteBehavior
POST /crm/contactsRequires firstName and lastName (400 MISSING_FIELDS). Writes those plus email, phone, title, lifecycleStage (default lead), leadSource, ownerId, customFields. companyId links the contact to that company as its primary. email must be unique in the organization (409 EMAIL_EXISTS)
GET /crm/contactsFilters: lifecycleStage, ownerId, leadSource, status, companyId, search (first name, last name, email)
GET /crm/contacts/:idIncludes linked companies, linked deals, and the 10 most recent activities
PUT /crm/contacts/:idWrites firstName, lastName, email, phone, title, lifecycleStage, leadSource, ownerId, status, customFields (merged). A changed email must also be unique. companyId is accepted and ignored
POST /crm/contacts/:id/associate-companyBody companyId (required UUID), role, isPrimary. Creates or updates the link and returns 201. isPrimary: true demotes the current primary
DELETE /crm/contacts/:idRefused with 409 CONTACT_IN_USE while anything references the contact; details lists the references

lifecycleStage is one of lead, contact, customer, evangelist, opportunity, other (400 INVALID_STAGE).

Companies

All five company routes are unscoped only.

RouteBehavior
POST /crm/companiesRequires name and type (400 MISSING_FIELDS; a type that is present but not allowed is 400 INVALID_TYPE). Writes those plus domain, industry, companySize, annualRevenue, phone, address, ownerId, paymentTerms, parentCompanyId, currencyCode, customFields. domain must be unique in the organization (409 DOMAIN_EXISTS)
GET /crm/companiesFilters: type, industry, ownerId, status, search (name, domain)
GET /crm/companies/:idIncludes linked contacts, the 10 most recent deals and the 10 most recent activities
PUT /crm/companies/:idWrites the create fields plus status; customFields merged
DELETE /crm/companies/:idRefused with 409 COMPANY_IN_USE while anything references the company, including a deal that names it as its referral company

type is one of customer, vendor, prospect, partner. annualRevenue is a number or a decimal string. currencyCode is the company’s account currency, a three-letter ISO 4217 code such as EUR, stored upper-case. A non-blank value that is not three letters is 400 VALIDATION_ERROR; a three-letter code Horizon does not know is 400 INVALID_CURRENCY. On PUT, null or an empty string clears it. A new deal for the company starts in this currency when the request names none. Company responses include currencyCode.

address is a JSON object. Horizon reads the street (street, then line2), city, state, postalCode and country from it and sets the company’s street, city, state, postalCode and country fields to match; street holds both lines, separated by a line break. On PUT, only the parts whose value differs from the object already stored change those fields; null, {} or an object with no changed part leaves them as they are. Company responses include all five fields.

Deals

RouteBehavior
POST /crm/dealsRequires name, stage and expectedCloseDate (400 MISSING_FIELDS). Writes those plus companyId, contactId, referralCompanyId, amount, currency (default: the company’s currencyCode, else USD), probability (integer 0-100), pipelineId, ownerId, notes, customFields, status and actualCloseDate. With no pipelineId, the deal joins your organization’s default pipeline when that pipeline has an open stage named exactly stage, and takes that stage’s probability unless you send one; otherwise it has no pipeline. status and actualCloseDate follow the stage, see below. Optional lineItems and contacts arrays. Returns 201 with company, referral company (id, name), contact, deal contacts and line items
GET /crm/dealsFilters: pipelineId, stage, status, ownerId, companyId, contactId, referralCompanyId, search (name). Each row includes the company’s id and name, the referral company’s id and name, and the contact’s id and names
GET /crm/deals/:idIncludes company, referral company (id, name), contact, pipeline, deal contacts, line items and the 20 most recent stage changes
PUT /crm/deals/:idWrites name, companyId, contactId, referralCompanyId, amount, currency, probability, pipelineId, ownerId, lossReason, notes, status, customFields, expectedCloseDate, actualCloseDate, stage. A status of null is ignored; a referralCompanyId of null clears the referral company. When stage or pipelineId changes and the deal ends up on a stage of its pipeline, probability (unless you send one), status and close date follow that stage
POST /crm/deals/:id/change-stageBody stage (required), lossReason, actualCloseDate. The same stage returns the deal unchanged. A stage in the deal’s pipeline sets probability, status and close date; a stage that is not, or a deal on no pipeline, moves the stage alone
DELETE /crm/deals/:idRefused with 409 DEAL_HAS_LIVE_BILLING while a sales order, a direct quote invoice or an agreement is billing the deal; details.billedBy is sales_order, quote or agreement. Void that billing first

A deal line item is { productId, description, quantity, unitPrice, discountPercent, taxRate, sortOrder }. Only unitPrice is required, and quantity defaults to 1. A deal contact is { contactId, role }. POST takes at most 200 lineItems and 100 contacts (400 VALIDATION_ERROR). referralCompanyId names the company that referred the deal. Send a UUID or null; an empty string is 400 VALIDATION_ERROR. POST, GET /crm/deals and GET /crm/deals/:id return that company as referralCompany (id, name), null when the deal has none. companyId, contactId, referralCompanyId and pipelineId, and on create each line’s productId and each contact’s contactId, must belong to your organization (400 INVALID_COMPANY, INVALID_CONTACT, INVALID_REFERRAL_COMPANY, INVALID_PIPELINE or INVALID_PRODUCT), and nothing is written otherwise.

How taxRate is applied depends on whether the crm-pricing-canonical feature is on for your organization: on, it is a fraction (0.0825 is 8.25%); off, the stored line total treats it as a percent. The schema caps it at 9.9999 either way. Before a line’s total is worked out, quantity and unitPrice are rounded half up to four decimals, discountPercent to two and taxRate to four, and the line stores the rounded values.

Status follows the stage. A deal on a stage marked as won is won, on a stage marked as lost is lost, and on any other stage open. A deal that is won or lost has an actualCloseDate: the date you send, or otherwise the moment it closed. A deal moved from one won stage to another, or from one lost stage to another, keeps the close date it has unless you send a new one. Moving a won or lost deal to an open stage reopens it and clears the close date. Every stage change on PUT and change-stage records a stage-history row.

A status you send must agree with the stage the deal ends up on. won or lost is accepted for a deal on an open stage when its pipeline has no stage for that status, and for a deal whose stage is not in a pipeline. open is accepted on a won or lost stage when the pipeline has no open stage. Sending the status a deal already has changes nothing. actualCloseDate cannot be in the future: the latest date accepted is today in UTC+14 (400 VALIDATION_ERROR).

Statuserror.codeCause
400INVALID_STATUSstatus is not open, won or lost
422STATUS_VIA_STAGEThe pipeline has a stage for that status. Create or move the deal on that stage instead
422STATUS_CONFLICTS_WITH_STAGEThe deal is on a won or lost stage, so it has that status. Move it to another stage to change it
422CLOSE_DATE_ON_OPEN_DEALactualCloseDate is sent for a deal that ends up open
422CLOSE_DATE_REQUIREDPUT or change-stage sends actualCloseDate: null for a deal that ends up won or lost

Winning a deal. When a PUT that changes stage, or a change-stage, moves a deal onto a stage of its pipeline marked as won, Horizon sets the deal’s company’s type to customer, and its payment terms to net 30 if it has none. Then, if your organization has Finance and the order-entry-quote-picker feature is off, it creates a Finance customer and a draft sales order, from the deal’s accepted quote, or from its line items when it has no accepted quote. Winning the same deal again does not make a second order. Creating a deal on a won stage does none of this.

The deal is saved before Finance runs, so the win stands whatever Finance does. When Finance cannot make the order for one of the reasons below, the call still answers 200, no Finance customer or sales order is created, a row on the deal’s History in Horizon says why, and data carries financeHandoff beside the deal’s fields:

json
{
  "data": {
    "id": "…",
    "stage": "Closed Won",
    "status": "won",
    "financeHandoff": {
      "setting": "sales_order",
      "outcome": "needs_legal_entity",
      "error": {
        "code": "AMBIGUOUS_LEGAL_ENTITY",
        "message": "This organization has more than one legal entity and none is set as primary, so Finance cannot tell which one the sales order belongs to. Set a primary legal entity in Settings > Entities."
      }
    }
  }
}
outcomeCauseOther fields
needs_legal_entityChecked first. Your organization has no legal entity (NO_LEGAL_ENTITY), none that is active (FINANCE_LEGAL_ENTITY_NOT_ACTIVE), or several and none set as primary (AMBIGUOUS_LEGAL_ENTITY)error.code, and error.message saying what to set up
needs_quoteNothing to order: the deal has no line items and no accepted quote, or its accepted quote has no line itemsquoteId, when the accepted quote has no line items

setting is always sales_order. Otherwise financeHandoff is absent: the sales order was made or already existed, the deal has no company to make one for, your organization does not have Finance, or the order-entry-quote-picker feature is on. Any other failure in the Finance step is answered as an error, with the deal already saved.

Quotes

All eight quote routes are unscoped only. A quote moves through these statuses:

FromTo
draftsent
sentaccepted, rejected, expired
expireddraft
accepted, rejectednone
RouteBehavior
POST /crm/quotesRequires lineItems with at least one item (400 MISSING_LINE_ITEMS) and at most 200 (400 VALIDATION_ERROR). Writes dealId, companyId, contactId, validUntil, currency (default USD), notes, terms. dealId, companyId, contactId and each line’s productId must belong to your organization (400 INVALID_DEAL, INVALID_COMPANY, INVALID_CONTACT or INVALID_PRODUCT), and nothing is written otherwise. The quote starts as draft; the server assigns quoteNumber and computes the totals. Returns 201 with line items
GET /crm/quotesFilters: dealId, companyId, status. Each row includes line items and the company’s and deal’s id and name
GET /crm/quotes/:idIncludes line items, company, and the deal’s id, name and stage
PUT /crm/quotes/:idDrafts only (409 NOT_EDITABLE). Writes notes, terms, validUntil, currency (null is ignored). lineItems, when sent, replaces every line and recomputes the totals; at most 200 (400 VALIDATION_ERROR), and each line’s productId must belong to your organization (400 INVALID_PRODUCT)
POST /crm/quotes/:id/senddraft to sent, else 409 INVALID_TRANSITION
POST /crm/quotes/:id/statusBody status (required). A move the table above does not allow returns 409 INVALID_TRANSITION
POST /crm/quotes/:id/ordersSee below
DELETE /crm/quotes/:idDrafts only (409 NOT_DELETABLE)

A quote line item is { description, quantity, unitPrice, productId, discountPercent, taxRate, sortOrder }; description, quantity and unitPrice are required. The schema also accepts legalEntityId on create and dealId, companyId, contactId on update; these routes do not store them.

POST /crm/quotes/:id/orders turns an accepted quote into a sales order. Body { "definitionKey": "sales_order" }; it returns 201 { data: { id, orderNumber } }. Calling it again for the same quote returns the existing order with 201, but two concurrent calls can both create one. Refusals, in the order they are checked:

StatusCause
403, RFC 7807 bodyDimension-scoped caller
403 ENTITLEMENT_REQUIRED or 403 MODULE_HIDDEN_FOR_ENTITYThe organization lacks the Sales or Finance module
403, RFC 7807 bodyThe key creator’s role cannot write sales orders
404 NOT_FOUND, message Not foundThe order-entry-quote-picker feature is off for the organization
400 MISSING_DEFINITION_KEYNo definitionKey
422 UNKNOWN_DEFINITION_KEYdefinitionKey is not sales_order
404 NOT_FOUNDQuote not found or not visible
409 INVALID_TRANSITIONThe quote is not accepted
422 UNPROVISIONABLE_CUSTOMERThe quote has no company
422 EMPTY_QUOTEThe quote has no line items

Organization members

Every route needs system.users. Members are returned as { id, clerkUserId, email, name, role, deactivatedAt, createdAt }.

RouteBehavior
GET /usersOptional email (exact, case-insensitive) or q (substring of email or name). Sorted by name, then email. At most 200
GET /users/:memberId:memberId is a member UUID or a Clerk user id
POST /usersBody email, role. Adds an existing Clerk user with that email to the organization, or updates the role of one already in it. Returns 201. No Clerk user with the email returns 404 USER_NOT_FOUND
PATCH /users/:memberIdBody role. A deactivated member returns 409 MEMBER_IS_DEACTIVATED
DELETE /users/:memberIdRemoves the membership in Clerk and in Horizon. Returns { data: { deleted: true } }. The removed member’s keys stop working, see Key checks

role is one of owner, admin, department_lead, member, billing_admin, controller, accountant (400 VALIDATION otherwise). The key’s creator is the actor for every check:

Statuserror.codeCause
403ROLE_ABOVE_ACTORThe role ranks above the creator’s own
403OWNER_REQUIREDOnly an owner can make an owner
403CANNOT_MODIFY_SELFThe target is the key’s creator
403LAST_OWNERThe change would leave no active owner
409WOULD_LOCK_LAST_ADMINThe change would remove the organization’s last unscoped administrator