Deals
An opportunity — a titled, valued thing a contact or a company might buy — moving through the stages of a pipeline until it is won or lost.
Scopes and the site rule are shared with every CRM resource — see companies.
Deals are part of the CRM suite, included from Starter — a plan without it answers
403 plan_required with code: "crm". A deal is also a CRM record: it counts with
contacts and companies against the plan's records band, and on a plan that hard-bands
(Free) a create past the band answers 403 plan_required with
code: "crm_records_quota", with the key given back. See
companies for the same rule on the other collections.
The deal object
{
"id": "d_3c9a",
"object": "deal",
"title": "Wholesale beans — Q4",
"pipelineId": "p_7b2e",
"stageId": "proposal-price-quote",
"status": "open",
"amountCents": 250000,
"currency": "usd",
"lineItems": [
{ "productId": "prod_8c1d", "name": "House blend, 5 lb", "quantity": 50, "unitAmountCents": 4500, "currency": "usd" },
{ "productId": null, "name": "Delivery", "quantity": 1, "unitAmountCents": 25000, "currency": "usd" }
],
"expectedCloseAt": "2026-12-31T00:00:00.000Z",
"closedAt": null,
"stageChangedAt": "2026-09-05T18:23:23.941Z",
"ownerUid": "u_9f1c",
"contactId": "k7d2b9f104",
"contactRoles": [
{ "contactId": "k7d2b9f104", "role": "Decision Maker", "primary": true },
{ "contactId": "m2a8c4e771", "role": "Evaluator", "primary": false }
],
"companyId": "c_1a2b",
"lostReason": null,
"notes": null,
"type": "New Business",
"leadSource": "Trade show",
"nextStep": "Walk the second location with the owner",
"probability": null,
"forecastCategory": "bestCase",
"campaignId": "cmp_spring",
"custom": { "segment": "enterprise" },
"nextTaskAt": "2026-09-12T15:00:00.000Z",
"siteId": "site_a1b2c3",
"created": "2026-09-01T09:00:00.000Z",
"updated": "2026-09-05T18:23:23.941Z"
}
| Field | Type | Notes |
|---|---|---|
id | string | Opaque deal id. |
object | string | Always "deal". |
title | string | Required. Trimmed, truncated to 200 characters. Writable. |
pipelineId | string | The pipeline the deal is in. Writable on create only: a deal's stages are its pipeline's, and moving one between pipelines is a new deal in the other pipeline, not an edit. Omit it to use the default among the active pipelines — seeding one if the organization has none. An archived pipeline is refused. |
stageId | string | The stage the deal is in, one of its pipeline's stages[].id. Writable — see moving a deal. |
status | string | open, won or lost. Follows the stage's kind, so the two can never disagree on a stored deal. Writable — see moving a deal. |
amountCents | integer | null | The value, in the currency's minor unit. A whole number, 0 or more. Writable on a deal with no line items; with line items it is their sum — see line items. |
currency | string | Three-letter ISO 4217 code, lowercase. usd when never set. Writable; USD is stored as usd. On a deal with line items, changing it needs the lineItems resent in the new currency. |
lineItems | array | The products behind the amount, each { productId, name, quantity, unitAmountCents, currency }. Empty when the amount is typed. Writable — see line items. |
expectedCloseAt | string | null | ISO 8601 instant. Writable. |
closedAt | string | null | When the deal was won or lost. Set by a move into a closed stage and cleared by a move back to an open one. Read-only. |
stageChangedAt | string | null | When the deal last moved — what "stuck in stage" reports read. Read-only. |
ownerUid | string | null | The member responsible. Must be a member of your organization. Writable. |
contactId | string | null | The contact the deal is with — the Primary of contactRoles. Must exist. Writable: setting it makes that contact Primary, adding them to contactRoles with no role when the deal did not name them; null leaves the deal with no Primary. |
contactRoles | array | Every contact on the deal and the part each plays — Salesforce's Opportunity Contact Roles — each { contactId, role, primary }. See contact roles. A deal written before roles reads as its contactId, Primary, with role: null. Writable. |
companyId | string | null | The company the deal is with. Must exist. Writable. |
lostReason | string | null | Free text, 5,000 characters. Writable. |
notes | string | null | Free text, 5,000 characters. Writable. |
type | string | null | Salesforce's Type: one of the organization's deal types — New Business, Existing Business or its own (picklist). Matched in any case and stored as the list spells it; a value outside the active list is a 400 naming the values it allows, and a deal keeps a value it already holds. A create that sends none takes the list's default, when it has one. Writable. |
leadSource | string | null | Where the deal came from: one of the organization's lead sources, judged like type. Never defaulted; a lead's conversion stamps the lead's own. Writable. |
nextStep | string | null | What happens next, 255 characters. Writable. |
probability | integer | null | This deal's own chance of closing, 0–100, over its stage's; null uses the stage's. What the weighted forecast multiplies an open deal by. Every move clears it. Writable. |
forecastCategory | string | null | omitted, pipeline, bestCase, commit or closed — a fixed set, as in Salesforce. Every move sets it from the new stage's; null only on a deal stored before the field existed, which forecasts as its stage's. Writable. |
campaignId | string | null | The campaign the deal is attributed to (Salesforce's Primary Campaign Source). Must be one of the organization's live campaigns; a deal keeps the one it names after that campaign is retired. Writable. |
custom | object | The organization's deal custom fields, keyed by field key; {} when the deal has none. Judged against the deal definitions: a key that is not one, a retired field, or a value the type cannot hold is a 400 naming custom.<key>. A PATCH merges the keys it sends; null clears one. Writable. |
nextTaskAt | string | null | When the earliest open task filed against the deal is due, or null when none is — its next activity, and what the console's "stuck deals" figure counts. Maintained by every task write, this resource included. Read-only. |
mediaIds | string[] | Files from the organization's media library attached to this record, by media id, at most 20. An empty array clears them. Writable. |
siteId | string | The site the deal was created from. Read-only. |
created / updated | string | null | ISO 8601. |
Endpoints
List deals
GET /v1/deals — scope crm:read. Paginated,
ordered by deal id unless updatedAfter is given.
| Param | Notes |
|---|---|
contactId | Deals with this contact. |
companyId | Deals with this company. |
pipelineId | Deals in this pipeline. |
ownerUid | Deals owned by this member. |
status | open, won or lost. Anything else is a 400 — ?status=closed matching nothing would be a plausible page, and a plausible page is the one you don't check. |
type | Deals of this type, spelled exactly as stored. |
leadSource | Deals from this lead source, spelled exactly as stored. |
forecastCategory | Deals in this forecast category. |
campaignId | Deals attributed to this campaign. |
updatedAfter | Deals updated after this instant, oldest change first — the sync filter. |
limit, cursor | Standard pagination. |
# everything open for one account
curl "https://app.aglyn.com/api/v1/deals?companyId=c_1a2b&status=open" \
-H "Authorization: Bearer aglyn_sk_…"
Only one filter narrows the query itself — in the order listed, so an id before a status — and the rest are checked on the page. A combined filter can therefore return a short page.
Retrieve a deal
GET /v1/deals/{dealId} — scope crm:read. Returns a deal, or 404 not_found
("No such deal").
Add a deal
POST /v1/deals — scope crm:write. Accepts an
Idempotency-Key, scoped to the organization.
Body — title is required. consentSiteId names the site the record came from; leave it out for a record from no site, which belongs to the whole organization. It is required only when your organization keeps each site's records apart. pipelineId picks the pipeline;
stageId and/or status place the deal in it (see below); neither means
the first open stage of the pipeline.
curl -X POST "https://app.aglyn.com/api/v1/deals" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Idempotency-Key: 9a41f0c2-…" \
-H "Content-Type: application/json" \
-d '{"title":"Wholesale beans — Q4","amountCents":250000,"currency":"usd","contactId":"k7d2b9f104","companyId":"c_1a2b","ownerUid":"u_9f1c","consentSiteId":"site_a1b2c3"}'
Returns 201 with the created deal, or 200 with the original when an
Idempotency-Key replays.
Update a deal
PATCH /v1/deals/{dealId} — scope crm:write. Takes no Idempotency-Key. Send only
what changes; an omitted key is left alone, an explicit null clears an optional
field, and {} is a no-op.
Moving a deal
A deal moves by stageId, by status, or by both:
stageIdpicks a stage, andstatusfollows the stage'skind. Moving to the pipeline'swonstage marks the deal won.statusalone picks the pipeline's onewonorloststage — or, foropen, its first open stage. A reopened deal has to land somewhere, and the top of the pipeline is the only somewhere that needs no second field.- Both together must agree.
{"stageId":"won","status":"open"}is a400namingstatus("Must match the stage, which is won") rather than a guess in favor of either, because whichever one we picked would be the one you did not mean.
Every move stamps stageChangedAt. A move into a won or lost stage sets
closedAt; a move back to an open stage clears it.
Every move also sets forecastCategory from the new stage and clears
probability back to null, so the deal forecasts at the new stage's odds —
as Salesforce re-defaults a probability when the stage changes. A body that
sends either beside the move keeps what it sent:
{"stageId":"negotiation-review","probability":95} moves the deal and sets
95%.
A move into the won stage — and a POST that creates a deal there — also
sets the linked contact's lifecycle stage to customer for the deal's site,
the way an order does: a contact with no stage or an earlier one becomes a
customer, and a later stage (evangelist, other) is never lowered. Read it
back on the contact — the deal's own representation does not
change. There is no flag to turn this off; a won deal is a customer by
definition.
Contact roles
contactRoles is the whole list, replaced as sent:
| Field | Type | Notes |
|---|---|---|
contactId | string | A contact of the organization. Each contact once. |
role | string | null | One of the organization's contact roles — Salesforce's Business User, Decision Maker, Economic Buyer, Economic Decision Maker, Evaluator, Executive Sponsor, Influencer, Technical Buyer, Other, or its own (picklist). Judged like type; a contact keeps the role it already holds on the deal after the value is deactivated. null or omitted for none. |
primary | boolean | At most one entry. Omitted is false. |
At most fifty contacts. The Primary is contactId: the deal stores the
Primary's id there, and a list with no Primary clears it. A body that sends
both must agree — contactId naming the Primary, or null beside a list with
none — or it is a 400 naming contactId. A body that sends contactId
alone keeps the list and makes that contact Primary. A refusal is named under
fields.contactRoles: "At most one contact may be Primary",
"Entry 1: c_9 is on the list twice", "No such contact in this organization: c_9",
or the roles the list allows.
curl -X PATCH "https://app.aglyn.com/api/v1/deals/d_3c9a" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-d '{"contactRoles":[{"contactId":"k7d2b9f104","role":"Decision Maker","primary":true},{"contactId":"m2a8c4e771","role":"Evaluator"}]}'
Deleting or erasing a contact takes them off every deal's contactRoles (and
clears contactId where they were the Primary); merging moves
them to the surviving contact.
Line items
lineItems is the list of products behind the amount. Each line is:
| Field | Type | Notes |
|---|---|---|
productId | string | null | A catalog product the line came from, or null for a line priced by hand. Not checked against the catalog. |
name | string | Required. Trimmed, truncated to 120 characters. Copied rather than joined, so a renamed or deleted product does not change what the deal was for. |
quantity | integer | A whole number, 1 to 1,000,000. |
unitAmountCents | integer | Per unit, in the currency's minor unit. A whole number, 0 or more. |
currency | string | Optional on write; defaults to the deal's. Must equal the deal's currency — the lines' sum is one number in one unit. |
At most fifty lines. The rules:
- With one or more lines,
amountCentsis their sum, written by the server beside them. SendingamountCentsin the same body is a400namingamountCents; so is sending it alone to a deal that has lines. lineItems: [](ornull) clears the lines and hands the amount back to you. The last sum stays until you retype it.- Changing
currencyon a deal with lines is a400namingcurrencyunless the body also carrieslineItemsin the new currency. - A refused line is named —
"Line 2: the quantity must be a whole number from 1 to 1,000,000"— underfields.lineItems.
curl -X PATCH "https://app.aglyn.com/api/v1/deals/d_3c9a" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-d '{"lineItems":[{"productId":"prod_8c1d","name":"House blend, 5 lb","quantity":50,"unitAmountCents":4500},{"name":"Delivery","quantity":1,"unitAmountCents":25000}]}'
The deal comes back with amountCents: 250000.
curl -X PATCH "https://app.aglyn.com/api/v1/deals/d_3c9a" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-d '{"status":"won","amountCents":262500}'
Delete a deal
DELETE /v1/deals/{dealId} — scope crm:write. Accepts an
Idempotency-Key, scoped to the organization.
{ "id": "d_3c9a", "object": "deal", "deleted": true }
The deal alone is removed; its tasks and activities keep their dealId. A missing
deal is 404 not_found, unless the call carries the key of the delete that removed
it.
Errors
| Status | type | When |
|---|---|---|
400 | bad_request | code: "validation_failed" — a missing title, a missing consentSiteId where the organization keeps its sites apart, a pipelineId or stageId that does not exist, an archived pipelineId, a status that disagrees with the stageId, a type or leadSource outside the organization's active lists, a probability that is not a whole number 0–100, a forecastCategory outside the five, a campaignId that is not one of the organization's live campaigns, an amountCents that is not a whole number 0 or more or that is sent to a deal with line items, a currency that is not a three-letter code or that a deal's line items are not in, a line item that fails its rules, an expectedCloseAt that is not an ISO 8601 instant, a contactId or companyId that does not exist, an ownerUid who is not a member, a custom entry that is not a deal field or does not fit its type (named as custom.<key>), or pipelineId on a PATCH. On the list, a ?status= outside the three values or a malformed ?updatedAfter=. fields names each key. |
403 | plan_required | code: "crm" — the plan doesn't include the CRM suite. code: "crm_records_quota" — the CRM records band is full on a plan that doesn't meter the overage. |
403 | insufficient_scope | Key lacks crm:read / crm:write. |
404 | not_found | "No such deal". |
405 | method_not_allowed | Allow: GET, POST on /v1/deals, GET, PATCH, DELETE on one deal. |
409 | conflict | code: "idempotency_in_progress". |
See Conventions → Errors for the shared envelope.
Related
- Pipelines — the stages a deal's
stageIdnames. - Tasks and activities — the work and the history
against a deal, each filterable by
dealId.