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:
| Routes | Key accepted | Enforced by |
|---|---|---|
/api/v2/agent/* | Yes, and only keys: a signed-in session’s token gets 401 | Each route checks the key itself, plus one permission per group of routes, as described on this page |
/api/v2/forms/* | Yes | The 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 /meetings | Yes | The key’s permission for that module and feature |
Every other /api/v2 path | No. 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:
{
"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.reason | Meaning |
|---|---|
session_only | The path serves signed-in users only |
retired | The path belonged to a retired API. details.movedTo lists the paths to use instead |
no_route | No 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:
{
"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 entitlement | Not checked, except on POST /crm/quotes/:id/orders | Checked: a module your organization does not have is refused |
| Board role | None. The key’s board access is the whole of its board authorization | The key acts as its creator, and the creator’s board access applies |
| Key’s creator removed or deactivated | Refused, see Key checks | Refused the same way |
| Rate limit | 1,000 requests per 60 seconds per key, see Rate limits | 100 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
| Header | Required | Effect |
|---|---|---|
Authorization: Bearer hzn_... | Yes | The API key. Anything not starting Bearer hzn_ is refused |
Content-Type: application/json | For bodies | JSON bodies up to 10 MB. Larger bodies get 413 PAYLOAD_TOO_LARGE |
X-Active-Legal-Entity | No | A legal-entity id, or * for all entities. Only has an effect when legal-entity separation is on, see below |
X-Horizon-Agent-Deployment-Id | No | Makes the caller an agent deployment instead of the key’s creator, see Actor |
X-Request-Id | No | Echoed 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:
| Status | error.code | Message |
|---|---|---|
| 401 | UNAUTHORIZED | Missing or malformed API key |
| 401 | UNAUTHORIZED | Invalid API key (unknown, or its status is not active) |
| 401 | UNAUTHORIZED | API key expired |
The key’s creator must be an active member of the key’s organization:
| Status | error.code | When |
|---|---|---|
| 401 | KEY_OWNER_NOT_FOUND | The 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 |
| 403 | MEMBER_DEACTIVATED | The 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:
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests" } }| Header | Sent on | Value |
|---|---|---|
X-RateLimit-Limit | Every counted response, including the 429 | 1000 |
X-RateLimit-Remaining | The same | Requests left in the window, never below 0 |
Retry-After | The 429 only | Seconds 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
401or403, still counts. - Separate from the other routes. The other
/api/v2routes 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-Afterseconds 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.
| Routes | Permission |
|---|---|
/boards, and its /columns, /sections, /members | company.boards |
/boards/:boardId/tasks, /tasks | company.tasks |
/tasks/:taskId/comments | company.task-comments and company.tasks W, see below |
/boards/:boardId/tabs | company.board-tabs |
/labels | company.labels |
/users | system.users |
/crm/contacts | crm.contacts |
/crm/companies | crm.companies |
/crm/deals | crm.deals |
/crm/quotes | crm.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.
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 access | Stored as | Boards the key reaches |
|---|---|---|
| All boards | all | Every board in the organization, private boards included |
| Only selected boards | selected | The boards stored on the key |
| Only boards this key’s owner has been added to | member | The 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:
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "Your access is limited to specific dimension values. Sales covers all values and cannot be filtered."
}Legal-entity separation
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-Entityhas no effect, and no403comes 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-Entitynaming an entity the creator cannot access returns403 ENTITY_ACCESS_DENIED, as does*under Strict unless the creator has all-entities access or is an owner or admin.PUTandDELETEon a company the active entity does not own return403 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-apiconnection whose stored key is this key. Otherwise403 AGENT_DEPLOYMENT_NOT_VERIFIED. The deployment must also beactive: one that ispaused,configuring,errororremovedgets403 AGENT_DEPLOYMENT_NOT_ACTIVE. The request is never downgraded to the creator.
Writes are attributed as follows:
createdBy,updatedBy,completedByand stage-historychangedByare 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
editormember, 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.
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.
| Method | Path | Permission | Unscoped only | Purpose |
|---|---|---|---|---|
GET | /boards | company.boards R | List boards in scope | |
POST | /boards | company.boards W | Create a board | |
GET | /boards/:boardId | company.boards R | Get a board with columns, sections, tabs, members | |
PUT | /boards/:boardId | company.boards W | Update a board | |
DELETE | /boards/:boardId | company.boards D | Delete a board | |
POST | /boards/:boardId/columns | company.boards W | Add a column | |
PUT | /boards/:boardId/columns/reorder | company.boards W | Reorder columns | |
PUT | /boards/:boardId/columns/:columnId | company.boards W | Update a column | |
DELETE | /boards/:boardId/columns/:columnId | company.boards D | Delete a column | |
POST | /boards/:boardId/sections | company.boards W | Add a section | |
PUT | /boards/:boardId/sections/reorder | company.boards W | Reorder sections | |
PUT | /boards/:boardId/sections/:sectionId | company.boards W | Update a section | |
DELETE | /boards/:boardId/sections/:sectionId | company.boards D | Delete a section | |
GET | /boards/:boardId/members | company.boards R | List board members | |
POST | /boards/:boardId/members | company.boards W | Add a board member | |
PUT | /boards/:boardId/members/:memberId | company.boards W | Change a member’s role | |
DELETE | /boards/:boardId/members/:memberId | company.boards D | Remove a board member | |
GET | /boards/:boardId/tabs | company.board-tabs R | List saved views | |
POST | /boards/:boardId/tabs | company.board-tabs W | Create a saved view | |
PUT | /boards/:boardId/tabs/reorder | company.board-tabs W | Reorder saved views | |
PUT | /boards/:boardId/tabs/:tabId | company.board-tabs W | Update a saved view | |
DELETE | /boards/:boardId/tabs/:tabId | company.board-tabs D | Delete a saved view | |
GET | /boards/:boardId/tasks | company.tasks R | List a board’s tasks | |
POST | /boards/:boardId/tasks | company.tasks W | Create a task | |
GET | /tasks?q= | company.tasks R | Search task titles across boards | |
GET | /tasks/:taskId | company.tasks R | Get a task | |
PUT | /tasks/:taskId | company.tasks W | Update a task | |
DELETE | /tasks/:taskId | company.tasks D | Delete a task | |
PUT | /tasks/:taskId/move | company.tasks W | Move a task, within or across boards | |
POST | /tasks/:taskId/subtasks | company.tasks W | Create a subtask | |
POST | /tasks/:taskId/comments | company.task-comments W, company.tasks W | Comment on a task | |
GET | /labels | company.labels R | List task labels | |
POST | /labels | company.labels W | Create a label | |
PUT | /labels/:labelId | company.labels W | Update a label | |
DELETE | /labels/:labelId | company.labels D | Delete a label | |
GET | /users | system.users R | List organization members | |
GET | /users/:memberId | system.users R | Get a member | |
POST | /users | system.users W | Add an existing Clerk user to the organization | |
PATCH | /users/:memberId | system.users W | Change a member’s role | |
DELETE | /users/:memberId | system.users D | Remove a member from the organization | |
GET | /crm/contacts | crm.contacts R | List contacts | |
POST | /crm/contacts | crm.contacts W | Yes | Create a contact |
GET | /crm/contacts/:id | crm.contacts R | Get a contact | |
PUT | /crm/contacts/:id | crm.contacts W | Update a contact | |
POST | /crm/contacts/:id/associate-company | crm.contacts W | Yes | Link a contact to a company |
DELETE | /crm/contacts/:id | crm.contacts D | Yes | Delete a contact |
GET | /crm/companies | crm.companies R | Yes | List companies |
POST | /crm/companies | crm.companies W | Yes | Create a company |
GET | /crm/companies/:id | crm.companies R | Yes | Get a company |
PUT | /crm/companies/:id | crm.companies W | Yes | Update a company |
DELETE | /crm/companies/:id | crm.companies D | Yes | Delete a company |
GET | /crm/deals | crm.deals R | List deals | |
POST | /crm/deals | crm.deals W | Create a deal | |
GET | /crm/deals/:id | crm.deals R | Get a deal | |
PUT | /crm/deals/:id | crm.deals W | Update a deal | |
POST | /crm/deals/:id/change-stage | crm.deals W | Move a deal to another stage | |
DELETE | /crm/deals/:id | crm.deals D | Delete a deal | |
GET | /crm/quotes | crm.quotes R | Yes | List quotes |
POST | /crm/quotes | crm.quotes W | Yes | Create a draft quote |
GET | /crm/quotes/:id | crm.quotes R | Yes | Get a quote |
PUT | /crm/quotes/:id | crm.quotes W | Yes | Edit a draft quote |
POST | /crm/quotes/:id/send | crm.quotes W | Yes | Mark a draft quote sent |
POST | /crm/quotes/:id/status | crm.quotes W | Yes | Change a quote’s status |
POST | /crm/quotes/:id/orders | crm.quotes W | Yes | Create a sales order from an accepted quote |
DELETE | /crm/quotes/:id | crm.quotes D | Yes | Delete 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:
{
"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:
| Status | error.code | Cause |
|---|---|---|
| 400 | VALIDATION_ERROR | A body failed its schema. details lists { path, message } per issue |
| 400 | INVALID_JSON | The body is not valid JSON |
| 400 | INVALID_BODY_ENCODING | The body is labelled gzip, deflate or br but does not decompress |
| 400 | MISSING_FIELDS, MISSING_PARAM | A required field or query parameter is absent |
| 400 | INVALID_UUID | An id field is not a UUID |
| 400 | INVALID_COMPANY, INVALID_CONTACT, INVALID_DEAL, INVALID_PIPELINE, INVALID_PRODUCT, INVALID_PARENT | A referenced id is not in the organization. Message: <field> not found in this org |
| 404 | NOT_FOUND | Not found, out of the key’s board scope, or hidden by legal-entity separation |
| 409 | varies | A state conflict, listed per resource below |
Pagination
Two styles, both newest first (createdAt descending):
| Endpoints | Query | Response | Limits |
|---|---|---|---|
/crm/contacts, /crm/companies, /crm/deals, /crm/quotes | limit, cursor | { data, cursor, hasMore }. cursor is the last row’s id, or null on the last page | Default 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 last | Default 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:
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | Yes | At least one character |
columnId | string | Yes | Must be a column on this board, else 400 INVALID_COLUMN |
description | string or null | No | |
sectionId | string or null | No | |
priority | none, low, medium, high, urgent | No | |
assigneeType | user, agent, or null | No | |
assigneeUserId | string or null | No | A Clerk user id |
assigneeAgentDeploymentId | string or null | No | Must be a deployment in the organization, else 404 NOT_FOUND |
dueDate | date string or null | No |
Two kinds of field are refused rather than ignored, anywhere in a task body:
agentQueueStatus,agentQueueOrder,agentConversationIdandskipQueueReconciliationreturn400 DISPATCH_OWNED_FIELD, even when the value isnull. The agent queue owns them.includereturns400 UNSUPPORTED_FIELD. The server chooses the returned shape.
Other unknown fields are dropped. labelIds is dropped on create; set labels with an update.
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:
| Field | Type | Notes |
|---|---|---|
columnId | string or null | Must be a column on the task’s current board |
status | not_started, in_progress, completed | Changing to completed sets completedAt and completedBy |
autonomyOverride | suggest_only, supervised, autonomous, or null | |
labelIds | string array | Replaces 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:
positionis 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 foundotherwise), andcolumnIdmust name one of its columns (400 INVALID_COLUMN). A cross-board move withoutcolumnIdfails 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.
List and search
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:
| Query | Required | Notes |
|---|---|---|
q | Yes | 400 MISSING_PARAM when absent |
boardId | No | Restricts to one board. Out of scope returns 404 Board not found |
limit, cursor | No | As 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
editorif 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
| Route | Body | Result |
|---|---|---|
POST /boards | name (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/:boardId | The board with columns, members, goal, a task count, sections and tabs | |
PUT /boards/:boardId | name, 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
| Route | Body |
|---|---|
POST /boards/:boardId/columns | name, color, position. Without position the column is appended |
PUT /boards/:boardId/columns/:columnId | name, color, position |
PUT /boards/:boardId/columns/reorder | orderedIds: column ids in the new order |
POST /boards/:boardId/sections | name, position |
PUT /boards/:boardId/sections/:sectionId | name, position |
PUT /boards/:boardId/sections/reorder | orderedIds |
POST /boards/:boardId/members | memberType (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/:memberId | role only |
Deletes return { data: { id } }.
Saved views (tabs)
| Route | Body | Result |
|---|---|---|
POST /boards/:boardId/tabs | name (required), viewMode (list; any other value is stored as board), filters (validated, 400 INVALID_FILTERS) | 201 with the view |
PUT /boards/:boardId/tabs/:tabId | name, viewMode, filters | The view |
PUT /boards/:boardId/tabs/reorder | tabIds, 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
| Route | Behavior |
|---|---|
POST /crm/contacts | Requires 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/contacts | Filters: lifecycleStage, ownerId, leadSource, status, companyId, search (first name, last name, email) |
GET /crm/contacts/:id | Includes linked companies, linked deals, and the 10 most recent activities |
PUT /crm/contacts/:id | Writes 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-company | Body companyId (required UUID), role, isPrimary. Creates or updates the link and returns 201. isPrimary: true demotes the current primary |
DELETE /crm/contacts/:id | Refused 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.
| Route | Behavior |
|---|---|
POST /crm/companies | Requires 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/companies | Filters: type, industry, ownerId, status, search (name, domain) |
GET /crm/companies/:id | Includes linked contacts, the 10 most recent deals and the 10 most recent activities |
PUT /crm/companies/:id | Writes the create fields plus status; customFields merged |
DELETE /crm/companies/:id | Refused 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
| Route | Behavior |
|---|---|
POST /crm/deals | Requires 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/deals | Filters: 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/:id | Includes company, referral company (id, name), contact, pipeline, deal contacts, line items and the 20 most recent stage changes |
PUT /crm/deals/:id | Writes 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-stage | Body 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/:id | Refused 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).
| Status | error.code | Cause |
|---|---|---|
| 400 | INVALID_STATUS | status is not open, won or lost |
| 422 | STATUS_VIA_STAGE | The pipeline has a stage for that status. Create or move the deal on that stage instead |
| 422 | STATUS_CONFLICTS_WITH_STAGE | The deal is on a won or lost stage, so it has that status. Move it to another stage to change it |
| 422 | CLOSE_DATE_ON_OPEN_DEAL | actualCloseDate is sent for a deal that ends up open |
| 422 | CLOSE_DATE_REQUIRED | PUT 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:
{
"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."
}
}
}
}outcome | Cause | Other fields |
|---|---|---|
needs_legal_entity | Checked 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_quote | Nothing to order: the deal has no line items and no accepted quote, or its accepted quote has no line items | quoteId, 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:
| From | To |
|---|---|
draft | sent |
sent | accepted, rejected, expired |
expired | draft |
accepted, rejected | none |
| Route | Behavior |
|---|---|
POST /crm/quotes | Requires 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/quotes | Filters: dealId, companyId, status. Each row includes line items and the company’s and deal’s id and name |
GET /crm/quotes/:id | Includes line items, company, and the deal’s id, name and stage |
PUT /crm/quotes/:id | Drafts 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/send | draft to sent, else 409 INVALID_TRANSITION |
POST /crm/quotes/:id/status | Body status (required). A move the table above does not allow returns 409 INVALID_TRANSITION |
POST /crm/quotes/:id/orders | See below |
DELETE /crm/quotes/:id | Drafts 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:
| Status | Cause |
|---|---|
403, RFC 7807 body | Dimension-scoped caller |
403 ENTITLEMENT_REQUIRED or 403 MODULE_HIDDEN_FOR_ENTITY | The organization lacks the Sales or Finance module |
403, RFC 7807 body | The key creator’s role cannot write sales orders |
404 NOT_FOUND, message Not found | The order-entry-quote-picker feature is off for the organization |
400 MISSING_DEFINITION_KEY | No definitionKey |
422 UNKNOWN_DEFINITION_KEY | definitionKey is not sales_order |
404 NOT_FOUND | Quote not found or not visible |
409 INVALID_TRANSITION | The quote is not accepted |
422 UNPROVISIONABLE_CUSTOMER | The quote has no company |
422 EMPTY_QUOTE | The quote has no line items |
Organization members
Every route needs system.users. Members are returned as
{ id, clerkUserId, email, name, role, deactivatedAt, createdAt }.
| Route | Behavior |
|---|---|
GET /users | Optional 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 /users | Body 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/:memberId | Body role. A deactivated member returns 409 MEMBER_IS_DEACTIVATED |
DELETE /users/:memberId | Removes 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:
| Status | error.code | Cause |
|---|---|---|
| 403 | ROLE_ABOVE_ACTOR | The role ranks above the creator’s own |
| 403 | OWNER_REQUIRED | Only an owner can make an owner |
| 403 | CANNOT_MODIFY_SELF | The target is the key’s creator |
| 403 | LAST_OWNER | The change would leave no active owner |
| 409 | WOULD_LOCK_LAST_ADMIN | The change would remove the organization’s last unscoped administrator |