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}/close and POST /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/v1

List Deals

GET /api/v1/deals?pipelineId=UUID_HERE
GET /api/v1/deals?companyId=UUID_HERE

This endpoint answers in two shapes, and the parameter you send picks which:

  • pipelineId reads a board — one stage column per stage of that pipeline.
  • companyId reads 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 — with excludeDealId — behind the one on a deal record.

Query parameters, board mode:

ParameterTypeDefaultDescription
pipelineIdstring—Required. The pipeline whose board to read
stageIdstring—Return only this stage's column, to page one column on its own
limitnumber25Number of results per stage column (1-100)
offsetnumber0Pagination offset, within each stage column
querystring—Search by deal title or company name
sortBystring"createdAt"Sort field: title, value, expectedCloseDate, createdAt, updatedAt
sortOrderstring"asc"Sort direction: asc or desc
ownerIdstring—Comma-separated owner filter (e.g., UUID_A,UUID_B)
statusstring—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
ParameterTypeDefaultDescription
companyIdstring—Required in this mode. Only this company's deals
excludeDealIdstring—Leaves one deal out of the page and out of total
limitnumber25Number of results (1-100)
offsetnumber0Pagination offset
sortBystring"expectedCloseDate"Sort field: title, value, expectedCloseDate, createdAt, updatedAt
sortOrderstring"asc"Sort direction: asc or desc
statusstring—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/:id

Returns the deal with its company, person and owner resolved — see Response shape.


Create Deal

POST /api/v1/deals
Content-Type: application/json
FieldTypeRequiredDescription
stageIdstringYesThe stage the deal starts in
companyIdstringYesThe company the deal is with
personIdstringNoThe contact at that company. null for none
ownerIdstringNoThe member who owns the deal. null for none
titlestringYesDeal title (1-200 characters)
valuenumberNoAmount in integer hundredths, 0 or more (default: 0)
expectedCloseDatestringNoExpected 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/json

Body: 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/:id

Returns { "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
FieldTypeRequiredDescription
stageIdstringYesThe 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-history

Returns { "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
FieldTypeRequiredDescription
statusstringYeswon or lost — open is not accepted here
lostReasonstringWhen closing lostWhy 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/reopen

Bodyless. 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:

StatusWhen
400The deal ID in the path is not a UUID
400ownerId names a user who is not a member of the organization (create, update)
400The destination stage belongs to another pipeline (move)
404Pipeline or stage not found — the pipelineId on the list is unknown or belongs to another organization, or stageId is not one of its stages
404Deal not found, or a referenced stage, company or person that is not yours
422Validation 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"},
)

On this page