Deals
The REST API endpoints for managing deals — the opportunities moving through your pipelines.
What a deal is
A deal is one opportunity with one company: a title, an amount, the stage it currently sits in, and — optionally — the person it is being discussed with and the member of your organization who owns it. Every deal belongs to exactly one stage, and therefore to exactly one pipeline. See Pipelines for how stages are configured.
Amounts are integer hundredths of the deal's currency, like every other
money field in the API: a deal worth $4,800.00 has "value": 480000. The
currency belongs to the pipeline, not to the deal: every response carries a
read-only currency — an uppercase ISO 4217 code such as "USD" or "BRL" —
copied from the deal's pipeline on every read. It is not accepted on create or
update; a body carrying it is ignored like any other unknown field. The API
returns the number, not a formatted string, and nothing is ever converted
between currencies.
value is in hundredths whatever the currency: a currency with no minor unit
still counts in hundredths (¥1,000 is "value": 100000), and one with three
decimals, such as KWD, cannot carry its third digit.
A deal also has a status: open when it is still being worked, won or
lost once it has been closed. The status is deliberately not a field you can
write through the ordinary update:
- The stage is owned by
PATCH /deals/{id}/stage - The status is owned by
POST /deals/{id}/closeandPOST /deals/{id}/reopen
POST /deals and PATCH /deals/{id} accept neither, so no request can smuggle
a win past the rules the two verbs enforce.
Status is orthogonal to stage. Closing a deal does not move it: a deal won in Negotiation stays in Negotiation, and still appears in that column of the board. Reordering the pipeline's stages, likewise, never moves a deal.
Winning a deal moves the company forward
Closing a deal as won advances its company's lifecycle stage to customer
— but only from lead or disqualified, the two stages that precede it. A
company already at customer or formerCustomer keeps the stage it has: a
second won deal never rewrites that history, and a former customer is not
quietly demoted back to a current one.
The company is advanced in the same transaction as the close, so a company is never promoted for a win that then failed to persist. Closing as lost leaves the company's lifecycle stage untouched, and so does reopening: a lifecycle stage is a fact about the relationship, not a mirror of the deal that last changed it. Reopen a won deal and the company stays a customer.
The deals board
Deals in the sidebar opens a kanban: one column per stage of the selected pipeline, in the order the stages are configured, including the stages holding nothing. Each column header carries the stage name, the number of deals in it and the sum of their values — the totals for the whole stage, not for the cards currently on screen.
The pipeline is chosen in the selector at the top right, and the choice lives in
the address bar as ?pipeline=<id>. That makes the board a link: send someone
the URL and it opens the same funnel, filtered the same way, on their screen.
The last item of the selector goes to Manage pipelines, because the board is
where you notice the funnel is wrong. Opening /deals with no pipeline in the
URL falls back to your organization's first pipeline.
A column loads 25 cards at a time and fetches the next 25 as you scroll it — each column pages on its own, so a busy stage never starves the others. Cards are ordered by expected close date, soonest first, with deals that have no expected close date last; the date is on the card because it is what explains the order. A card also shows the deal's title, its company, its value and its owner, and badges the date red when it has passed and amber when it is within a week.
Moving a deal between stages
Drag a card into another column. The move is applied immediately and confirmed with a Moved to … message carrying Undo; if the server refuses it, the card returns to where it came from and the error is shown.
Every card also has a ⋯ menu with Move to…, a submenu of the pipeline's
stages with the deal's current stage greyed out. It is the same move, and it is
the way to move a deal from the keyboard or on a touch screen — where the drag
handle is the grip on the left edge of the card, leaving the rest of it free to
scroll and to tap through to the deal.
A moved card is held at the top of its new column for a few seconds, ringed and with its close date showing, before it takes its sorted place. The board is sorted by expected close date, so a card often belongs past the cards loaded so far and would otherwise vanish the moment you released it.
Moving a deal never changes its status, and a closed deal cannot be dragged at all: the stage a deal was won or lost in is the record the board exists to keep.
Every move is recorded, as is the stage a deal is created in, so a deal can say when it entered the stage it is on and which stages it has been through. That history drives the stage funnel on the deal's record page — see Working with Deals — and is readable over the API at Deal Stage History.
Marking a deal won or lost
Mark as won and Mark as lost are the last two items of the card's ⋯
menu. Winning asks only for confirmation — and says so — because it also moves
the deal's company to the customer lifecycle stage when the company was
still a lead or disqualified. Losing asks for a reason, which is required
and kept on the deal; it is what explains, months later, why a deal died where
it did.
Either way the deal keeps its stage and leaves the board, since the board shows
open deals by default. A closed deal's ⋯ menu offers Reopen in place of
the move and close items, which puts it back to open in the same stage.
Filtering the board
Two controls sit beside the pipeline selector:
-
Owner — narrows the board to deals owned by the members you pick.
-
Filters — a menu of switches, in two groups. Tick as many as you like; they combine.
- Show › Closed brings won and lost deals back into their columns, at the stage they were closed in, each badged with its outcome. Column headers then show an incl. closed marker, because their totals are no longer the open pipeline.
- Only › My deals only narrows the board to the deals you own.
The chip counts how many switches are ticked, and Clear filters at the foot of the menu unticks them all — it leaves the Owner filter alone.
Every filter, like the pipeline, is part of the URL (?owner=…,
?filter=closed,mine), so a filtered board is a link you can share.
Card density
In the heading, between the Filters menu and the gear, sits a pair of buttons, Medium cards and Compact cards. Medium is the board's default and the fuller card; compact takes each card down to two lines, so more of a long column fits on screen at once. The switch is immediate and there is nothing to confirm.
Density is part of the URL too, and nothing about the board is remembered on your device. A board at its defaults has a bare address; only what you actually changed is written to it. That means the link you paste opens for the next reader exactly as it looked for you.
A card's title opens the deal's own page, where the same close and reopen actions live alongside the deal's full detail. See Working with Deals for that page, and for the Deals tab of a company record.
Endpoints
All endpoints require a valid API key in the Authorization header. See Authentication for details.
Base URL: /api/v1List Deals
GET /api/v1/deals?pipelineId=UUID_HERE
GET /api/v1/deals?companyId=UUID_HEREThis endpoint answers in two shapes, and the parameter you send picks which:
pipelineIdreads a board — one stage column per stage of that pipeline.companyIdreads one company's deals as a flat page, across every pipeline they sit in. That is the shape behind the Deals tab of a company record, and — withexcludeDealId— behind the one on a deal record.
Query parameters, board mode:
| Parameter | Type | Default | Description |
|---|---|---|---|
pipelineId | string | — | Required. The pipeline whose board to read |
stageId | string | — | Return only this stage's column, to page one column on its own |
limit | number | 25 | Number of results per stage column (1-100) |
offset | number | 0 | Pagination offset, within each stage column |
query | string | — | Search by deal title or company name |
sortBy | string | "createdAt" | Sort field: title, value, expectedCloseDate, createdAt, updatedAt |
sortOrder | string | "asc" | Sort direction: asc or desc |
ownerId | string | — | Comma-separated owner filter (e.g., UUID_A,UUID_B) |
status | string | — | Comma-separated status filter (e.g., open,won) |
Status values: open, won, lost
query is split into words, and a deal matches when every word appears in
its title or its company's name — in any order, case-insensitive — so
proposal acme finds Acme's "Proposal" deal. A word that matches neither
excludes the deal. query takes up to 200 characters, and a longer one is a
422. Words past the eighth are ignored, and a query of only spaces is the
same as none.
A deal list is a board, not a flat page. data is an array of stage columns
— one per stage of the pipeline, in position order, including the stages that
hold nothing — each carrying its own page of deals and its own totals:
{
"data": [
{
"stage": {
"id": "4b7e...",
"pipelineId": "0c1f5b8e-...",
"name": "Qualified",
"position": 1
},
"deals": [],
"total": 12,
"totalValue": 4800000
}
],
"total": 37,
"totalValue": 12500000,
"pipelineId": "0c1f5b8e-...",
"currency": "USD"
}total and totalValue on a column count everything matching the filters in
that stage, not just the deals returned in deals — that is what lets a column
header read "12 deals · $48,000" while you have paged through three of them. The
top-level total and totalValue are the sums across the columns, and
currency is the pipeline's — every deal on a board shares it, so the sums are
never across currencies.
limit and offset page within each column, not across the board.
limit=25 returns up to 25 deals per stage, so a five-stage pipeline can
answer with 125 deals. A board-wide page would let one busy stage starve the
others of rows, so paging is per column — advance offset to scroll one
column further, exactly as the board does. sortBy and sortOrder apply
inside each column too.
Pass stageId to read a single column: data then holds that one stage
only, while its total and totalValue still describe the whole stage. That is
how a board fetches the next page of one column without disturbing the others.
The stage must belong to the pipeline named by pipelineId.
Deals inside a column are ordered by expectedCloseDate ascending with deals
that have no expected close date last, then by createdAt ascending, then by
id. The last two keys are tiebreaks, not decoration: expectedCloseDate is
optional and has day precision, so without them the same deal could come back on
two consecutive pages while another never appeared. A sortBy other than
expectedCloseDate replaces the first key and keeps the same tiebreaks.
A pipelineId that is missing or is not a UUID is refused with 422; one naming
a pipeline that does not exist, or belongs to another organization, with 404. A
stageId that is not a stage of that pipeline is refused with 404 too.
One company's deals
GET /api/v1/deals?companyId=UUID_HERE&status=open| Parameter | Type | Default | Description |
|---|---|---|---|
companyId | string | — | Required in this mode. Only this company's deals |
excludeDealId | string | — | Leaves one deal out of the page and out of total |
limit | number | 25 | Number of results (1-100) |
offset | number | 0 | Pagination offset |
sortBy | string | "expectedCloseDate" | Sort field: title, value, expectedCloseDate, createdAt, updatedAt |
sortOrder | string | "asc" | Sort direction: asc or desc |
status | string | — | Comma-separated status filter (e.g., open,won) |
companyId takes precedence: send it and pipelineId, stageId, query and
ownerId are ignored. The response is an ordinary flat list — data is an array
of deals and total counts every deal of that company matching the filters:
{
"data": [
{
"id": "9f2c...",
"title": "Annual renewal",
"value": 480000,
"currency": "BRL",
"status": "open",
"expectedCloseDate": "2026-10-15T00:00:00.000Z",
"stage": { "id": "4b7e...", "name": "Qualified", "pipelineId": "0c1f..." },
"company": { "id": "1a2b...", "name": "Acme Inc" },
"person": null,
"owner": null
}
],
"total": 3
}Every row names the stage it sits on, because a flat list has no column
header to name it. Deals are ordered by expectedCloseDate ascending with
undated deals last, then createdAt, then id — the same rule the board uses.
Unlike the board, closed deals are included by default: on one account a lost
deal is the context for the open one. Pass status=open to narrow to live work.
Pass excludeDealId to leave one deal out — the shape a deal's own page reads
when it lists the account's other deals. The deal is dropped from data and
from total alike, so a page of 25 stays a page of 25 and the count is the
number of deals the reader has not already got in front of them. An
excludeDealId naming a deal of another company, or of another organization,
matches nothing and changes nothing.
A companyId naming a company of another organization returns an empty page
rather than a 404 — the API does not confirm that an id you cannot see exists.
Get Deal
GET /api/v1/deals/:idReturns the deal with its company, person and owner resolved — see Response shape.
Create Deal
POST /api/v1/deals
Content-Type: application/json| Field | Type | Required | Description |
|---|---|---|---|
stageId | string | Yes | The stage the deal starts in |
companyId | string | Yes | The company the deal is with |
personId | string | No | The contact at that company. null for none |
ownerId | string | No | The member who owns the deal. null for none |
title | string | Yes | Deal title (1-200 characters) |
value | number | No | Amount in integer hundredths, 0 or more (default: 0) |
expectedCloseDate | string | No | Expected close date, e.g. 2026-10-15. null for none |
Responds 201 with the created deal. A new deal is always open: status is not
accepted here, and neither is lostReason.
stageId, companyId and personId must all name records in your
organization, and ownerId must name a member of it — a user who exists but
is not a member is refused with 400.
Update Deal
PATCH /api/v1/deals/:id
Content-Type: application/jsonBody: companyId, personId, ownerId, title, value and
expectedCloseDate, all optional. personId, ownerId and expectedCloseDate
accept null to clear them.
stageId and status are not part of this body. Move a deal with
PATCH /deals/{id}/stage; close or reopen it with the two verbs below.
Delete Deal
DELETE /api/v1/deals/:idReturns { "success": true } on success. A deal is deleted outright whatever its
status — there is nothing hanging off it to guard.
Move Deal to Stage
PATCH /api/v1/deals/:id/stage
Content-Type: application/json| Field | Type | Required | Description |
|---|---|---|---|
stageId | string | Yes | The destination stage, of the same pipeline |
Returns { "data": ... } — the moved deal.
Moving a card across the board is its own endpoint because a move is only legal within one pipeline: a destination stage on another pipeline is refused with 400 and the deal does not move. A move never changes the status, so a won deal dragged across the board stays won.
Deal Stage History
GET /api/v1/deals/:id/stage-historyReturns { "data": [...], "total": n } — when the deal last entered each stage
it has been through, oldest first:
{
"data": [
{ "stageId": "4b7e...", "enteredAt": "2026-08-01T09:12:44.000Z" },
{ "stageId": "9c02...", "enteredAt": "2026-08-19T16:03:10.000Z" }
],
"total": 2
}One entry per stage, not one per move: a deal that came back to a stage reports the latest time it entered it. A stage the deal has never been in is simply absent, so the list is at most as long as the pipeline has stages.
The history is written when the deal is created and on every move that actually
changes the stage — a move onto the stage the deal is already on records nothing.
Deals created before this was recorded answer with an empty list rather than a
404; the deal's own stageId still says where it is.
Its own endpoint rather than a field on the deal: the deal payload is the one every board card reads, and only the deal's record page draws the history.
Close Deal
POST /api/v1/deals/:id/close
Content-Type: application/json| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | won or lost — open is not accepted here |
lostReason | string | When closing lost | Why it was lost (1-5000 characters) |
Returns { "data": ... } — the closed deal, with closedAt set to the moment of
the close.
Closing as lost without a lostReason is refused with 422, and nothing is
written. Closing as won clears any lostReason a previous loss left behind,
and advances the company to customer when it is currently lead or
disqualified — see Winning a deal moves the company forward.
The deal keeps the stage it was in.
Reopen Deal
POST /api/v1/deals/:id/reopenBodyless. Returns { "data": ... } — the deal back at status: "open", with
closedAt and lostReason cleared.
Reopening leaves the company's lifecycle stage where the win put it, and leaves the deal in the stage it was in.
Errors
Beyond the shared 401 for a missing or invalid API key, and 400 for a request body that is not valid JSON:
| Status | When |
|---|---|
| 400 | The deal ID in the path is not a UUID |
| 400 | ownerId names a user who is not a member of the organization (create, update) |
| 400 | The destination stage belongs to another pipeline (move) |
| 404 | Pipeline or stage not found — the pipelineId on the list is unknown or belongs to another organization, or stageId is not one of its stages |
| 404 | Deal not found, or a referenced stage, company or person that is not yours |
| 422 | Validation failed — a missing pipelineId, a negative or non-integer value, an empty title, a status other than won/lost on the close, or a lost close with no lostReason |
Validation failures answer with the flattened Zod issues, so the offending field names itself:
{
"error": "Validation failed",
"details": {
"formErrors": [],
"fieldErrors": {
"lostReason": ["Required when closing a deal as lost."]
}
}
}Deals also guard deletion elsewhere in the API. A company that still has deals
cannot be deleted — see Companies — and neither can a pipeline
stage that still holds them, which asks for a moveToStageId instead; see
Pipelines.
Response shape
A deal:
{
"id": "3f9c1a2b-...",
"organizationId": "9a2d...",
"stageId": "4b7e...",
"companyId": "0c1f5b8e-...",
"personId": "7d21...",
"ownerId": "5e88...",
"title": "Annual licence renewal",
"value": 480000,
"currency": "USD",
"status": "open",
"lostReason": null,
"expectedCloseDate": "2026-10-15T00:00:00.000Z",
"closedAt": null,
"createdAt": "2026-09-01T14:54:01.000Z",
"updatedAt": "2026-09-01T14:54:01.000Z",
"company": { "id": "0c1f5b8e-...", "name": "Acme Inc." },
"person": { "id": "7d21...", "firstName": "Dana", "lastName": "Reeves" },
"owner": { "id": "5e88...", "name": "Sam Ortiz", "image": null },
"counts": { "comments": 0 }
}Every read carries company, person and owner resolved rather than three
foreign keys you would have to look up one board column at a time. person and
owner are null when the deal has none. counts is only present on
Get Deal, not on the board list, and currently carries the
comment thread's size.
value is in hundredths of currency, the pipeline's. closedAt is null
while the deal is open, and
lostReason is null unless the deal is closed as lost.
expectedCloseDate, closedAt, createdAt and updatedAt are ISO 8601 in UTC,
and value is a raw number of hundredths — never a formatted amount, and never
shifted into anyone's timezone. The app's screens localize both; the API
deliberately does not, whatever language it is showing you — see
Data Formats.
Examples
cURL
# Read a pipeline's board
curl "https://your-app.com/api/v1/deals?pipelineId=UUID_HERE" \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..."
# Only the open deals of two owners, biggest first, 50 per column
curl "https://your-app.com/api/v1/deals?pipelineId=UUID_HERE&status=open&ownerId=UUID_A,UUID_B&sortBy=value&sortOrder=desc&limit=50" \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..."
# Every deal of one company, open and closed, as a flat page
curl "https://your-app.com/api/v1/deals?companyId=UUID_HERE" \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..."
# Create a deal worth $4,800.00
curl -X POST https://your-app.com/api/v1/deals \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{"stageId":"UUID_HERE","companyId":"UUID_HERE","title":"Annual licence renewal","value":480000,"expectedCloseDate":"2026-10-15"}'
# Update it — the stage and the status are not settable here
curl -X PATCH https://your-app.com/api/v1/deals/UUID_HERE \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{"value":520000,"ownerId":null}'
# Move it to another stage of the same pipeline
curl -X PATCH https://your-app.com/api/v1/deals/UUID_HERE/stage \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{"stageId":"OTHER_UUID_HERE"}'
# Read the stages it has been through, and when
curl "https://your-app.com/api/v1/deals/UUID_HERE/stage-history" \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..."
# Close it as won — the company becomes a customer if it was a lead
curl -X POST https://your-app.com/api/v1/deals/UUID_HERE/close \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{"status":"won"}'
# Close it as lost — a reason is required
curl -X POST https://your-app.com/api/v1/deals/UUID_HERE/close \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{"status":"lost","lostReason":"Went with a cheaper competitor"}'
# Reopen it — no body
curl -X POST https://your-app.com/api/v1/deals/UUID_HERE/reopen \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..."
# Delete it
curl -X DELETE https://your-app.com/api/v1/deals/UUID_HERE \
-H "Authorization: Bearer ba_a1b2c3d4e5f6..."JavaScript / TypeScript
const API_KEY = "ba_a1b2c3d4e5f6...";
const BASE_URL = "https://your-app.com/api/v1";
// Read a board: `data` is one entry per stage, in order
const response = await fetch(
`${BASE_URL}/deals?pipelineId=${pipelineId}&status=open&limit=50`,
{ headers: { Authorization: `Bearer ${API_KEY}` } },
);
const { data: columns, total, totalValue } = await response.json();
for (const column of columns) {
console.log(column.stage.name, column.total, column.totalValue / 100);
}
// Create a deal in the first stage
const createRes = await fetch(`${BASE_URL}/deals`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
stageId: columns[0].stage.id,
companyId,
title: "Annual licence renewal",
value: 480000,
}),
});
const { data: deal } = await createRes.json();
// Win it
await fetch(`${BASE_URL}/deals/${deal.id}/close`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status: "won" }),
});Python
import requests
API_KEY = "ba_a1b2c3d4e5f6..."
BASE_URL = "https://your-app.com/api/v1"
# Read a board
response = requests.get(
f"{BASE_URL}/deals",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"pipelineId": pipeline_id, "status": "open", "limit": 50},
)
board = response.json()
for column in board["data"]:
print(column["stage"]["name"], column["total"], column["totalValue"] / 100)
# Create a deal in the first stage
response = requests.post(
f"{BASE_URL}/deals",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"stageId": board["data"][0]["stage"]["id"],
"companyId": company_id,
"title": "Annual licence renewal",
"value": 480000,
},
)
deal = response.json()["data"]
# Lose it, with a reason
requests.post(
f"{BASE_URL}/deals/{deal['id']}/close",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"status": "lost", "lostReason": "Went with a cheaper competitor"},
)