CRM: Custom Fields, Custom Forms, Deals & Pipelines
This guide covers the extensibility and workflow automation features of the Semaswift CRM:
- Custom Fields — extend the built-in Contact, Account, Deal and Lead schemas with org-specific attributes.
- Custom Forms — versioned form templates that collect structured data from agents and automatically write values back to CRM records.
- Deals & Deal Stages — track revenue opportunities through a configurable sales funnel.
- Pipeline Definitions — author multi-stage, action-driven workflows for contacts or deals.
- Pipeline Runs — execute pipelines against individual contacts or deals, with full pause/resume/restart control.
All endpoints require a user JWT (Authorization: Bearer {user_jwt}) unless otherwise noted.
1. Custom Fields
Custom fields let organisations add their own attributes to the four CRM object types: contact, account, deal, and lead. Fields are defined once (a definition) and then values are stored on each individual record.
1.1 Field types
field_type | Stored as | Validation options |
|---|---|---|
text | UTF-8 string | — |
number | Integer | {"min": N, "max": N} |
currency | Number | {"min": N, "max": N, "currency": "USD"} |
date | ISO 8601 date string (YYYY-MM-DD) | — |
datetime | ISO 8601 datetime string | — |
boolean | true / false | — |
url | Validated URL string | — |
email | Validated email address | — |
picklist | Single choice | Options must be declared in picklist_options |
multiselect | One or more choices | Options must be declared in picklist_options |
Immutability:
keyandfield_typecannot be changed after creation. To change either, archive the old field and create a new one.
1.2 Create a custom field definition
POST /api/v1/crm/custom-fields
Authorization: Bearer {user_jwt}
Content-Type: application/json
Request:
{
"object_type": "contact",
"key": "annual_revenue",
"label": "Annual Revenue",
"field_type": "currency",
"validation_rules": { "min": 0, "currency": "USD" },
"section": "Company Info",
"position": 1
}
| Field | Required | Notes |
|---|---|---|
object_type | yes | One of contact, account, deal, lead |
key | yes | Machine-readable ID. Pattern: ^[a-z][a-z0-9_]{0,63}$. Unique per org + object_type. Immutable |
label | yes | Display name (1–150 chars) |
field_type | yes | See table above. Immutable |
picklist_options | yes (picklist/multiselect only) | {"options": [{"value": "opt_a", "label": "Option A"}, ...]} |
validation_rules | no | Type-specific JSON object |
section | no | Groups related fields in the UI |
position | no | Display order within the section (lower = first) |
Response (200):
{
"data": {
"id": 42,
"organization_id": 7,
"object_type": "contact",
"key": "annual_revenue",
"label": "Annual Revenue",
"field_type": "currency",
"picklist_options": null,
"validation_rules": { "min": 0, "currency": "USD" },
"section": "Company Info",
"position": 1,
"is_active": true,
"created_at": "2026-06-08T10:00:00Z",
"updated_at": "2026-06-08T10:00:00Z"
},
"meta": { "request_id": "req_abc123" }
}
1.3 Picklist / multiselect fields
For picklist and multiselect fields, picklist_options is required and must follow this structure:
{
"options": [
{ "value": "smb", "label": "Small Business" },
{ "value": "mid_market", "label": "Mid-Market" },
{ "value": "enterprise", "label": "Enterprise" }
]
}
Both value (machine-readable, used in stored data) and label (display) are required on every option. Empty strings are rejected.
1.4 List, update, and archive
| Method | Path | Description |
|---|---|---|
GET | /api/v1/crm/custom-fields/{id} | Get a single definition |
GET | /api/v1/crm/custom-fields | List all definitions. Query params: object_type=, section=, include_archived=true |
PATCH | /api/v1/crm/custom-fields/{id} | Update mutable fields: label, picklist_options, validation_rules, section, position |
POST | /api/v1/crm/custom-fields/{id}:archive | Archive a field (is_active = false). Existing values are preserved |
POST | /api/v1/crm/custom-fields:reorder | Batch-update sections and positions |
List results are ordered by (section ASC, position ASC). Archived fields are excluded unless include_archived=true.
Batch reorder
POST /api/v1/crm/custom-fields:reorder
Content-Type: application/json
{
"fields": [
{ "id": 42, "section": "Company Info", "position": 1 },
{ "id": 43, "section": "Company Info", "position": 2 },
{ "id": 44, "section": "Lead Details", "position": 1 }
]
}
Fields not included in the list keep their current section and position.
1.5 Using custom field values on records
Custom field values are stored in the JSONB custom_fields column on contact and account records. Pass them in CreateContact, UpdateContact, CreateAccount, and UpdateAccount requests:
{
"first_name": "Jane",
"last_name": "Doe",
"custom_fields": {
"annual_revenue": 500000,
"segment": "enterprise"
}
}
The service validates each key exists as an active custom field definition for the contact object type, and validates the value against the field's validation_rules. Unknown keys and type mismatches return 400 INVALID_ARGUMENT.
2. Custom Forms
Custom forms are versioned, reusable templates that define a structured set of fields for agents to fill out. When a form is submitted, the values are stored as an immutable submission and can optionally be written back to contact or account custom fields.
2.1 Key concepts
| Concept | Description |
|---|---|
| Form family | All versions of a form share the same name. The name is immutable across versions |
| Version | Monotonically increasing integer per family (v1, v2, …). CreateDraftFromPublished bumps it |
| Status | draft → published → archived. Only published forms accept submissions |
| Write-back | A form can automatically write a submitted value into a contact's or account's custom field in the same transaction |
2.2 Form field kinds
kind | What it is |
|---|---|
inline | Field defined directly in the form schema. Caller supplies type, validation, picklist_options |
reference | Field sourced from a custom field definition (custom_field_definition_id). Picklist options are snapshot-resolved at publish time |
section_header | Non-data UI divider. Does not produce a value in submissions. Does not count toward the "at least one field" publish rule |
2.3 Inline field types
text, long_text, number, currency, rating, boolean, date, datetime, url, email, phone, picklist, multi_picklist (alias: multiselect), file_upload, signature.
2.4 Create a form
POST /api/v1/crm/forms
Authorization: Bearer {user_jwt}
Content-Type: application/json
Request:
{
"name": "KYC Verification",
"description": "Collect identity documents from the contact.",
"schema": {
"fields": [
{
"key": "id_type",
"kind": "inline",
"label": "ID Type",
"type": "picklist",
"required": true,
"picklist_options": [
{ "value": "passport", "label": "Passport" },
{ "value": "national_id","label": "National ID" },
{ "value": "drivers", "label": "Driver's Licence" }
]
},
{
"key": "id_number",
"kind": "inline",
"label": "ID Number",
"type": "text",
"required": true,
"validation": { "max_length": 50 }
},
{
"key": "id_scan",
"kind": "inline",
"label": "Scan / Photo of ID",
"type": "file_upload",
"required": true,
"validation": { "max_files": 2, "max_size_mb": 5, "allowed_mime": ["image/jpeg","image/png","application/pdf"] }
},
{
"key": "segment",
"kind": "reference",
"label": "Segment",
"custom_field_definition_id": 43
}
],
"write_back": [
{
"field_key": "segment",
"target": "contact",
"custom_field_definition_id": 43
}
]
}
}
The form is created in draft status with version: 1. New forms always start here — submissions are rejected against drafts.
Response (200) returns the full FormDefinition object.
2.5 Form field structure
| Field | Kind | Required | Notes |
|---|---|---|---|
key | all | yes | Pattern ^[a-z][a-z0-9_]{0,99}$. Unique within the form. Stable identifier used in submission values and write-back |
kind | all | yes | inline, reference, or section_header |
label | inline, reference | yes | Display label |
label_override | reference | no | Override the referenced custom field's own label |
required | inline, reference | no | Defaults to false. Section headers never required |
type | inline | yes | See §2.3 |
validation | inline | no | Type-specific: {"max_length": N} for text; {"min": N, "max": N} for number; {"max_files": N, "max_size_mb": N, "allowed_mime": [...]} for file_upload |
custom_field_definition_id | reference | yes | Must resolve to an active custom field on the relevant object type |
picklist_options | inline picklist/multi_picklist | yes | Array of {value, label} objects |
2.6 Write-back entries
Write-back makes a submission automatically update a contact's or account's custom field in the same database transaction. Failures (validation error, field archived, no subject ID) are reported per-entry in the submission response and do not abort the submission.
{
"field_key": "segment",
"target": "contact",
"custom_field_definition_id": 43
}
| Field | Description |
|---|---|
field_key | Form field whose submitted value is the data source |
target | contact or account. Resolved from the submission's contact_id / account_id |
custom_field_definition_id | The custom field to write into. Must apply to the matching object_type |
2.7 Publish a form
POST /api/v1/crm/forms/{id}/publish
Authorization: Bearer {user_jwt}
Publishing:
- Validates the form against the full ruleset
- Snapshots picklist options from any
reference-kind fields into the schema - Flips status to
published— the form now accepts submissions - Makes the schema immutable
Fails with FAILED_PRECONDITION if the form has no non-section fields (ErrFormEmpty), is not in draft status, or any referenced form action (in a pipeline) points to a non-published form.
2.8 Edit a published form (versioning)
You cannot patch a published form directly. To make changes:
- Fork a new draft from the published form:
POST /api/v1/crm/forms/{id}/drafts
This creates a new draft at version = N+1 with the same name. The published v1 remains live and in-flight submissions still target it.
- Patch the draft (schema, description):
PATCH /api/v1/crm/forms/{id}
- Publish the new draft.
Existing submissions against earlier versions are unaffected — they embed a schema_snapshot at submit time.
2.9 Archive a form
POST /api/v1/crm/forms/{id}/archive
Archived forms reject new submissions. Historical submissions remain queryable. Idempotent.
2.10 Delete a form
DELETE /api/v1/crm/forms/{id}
Only draft or archived forms with no submissions can be deleted. Forms with any submissions must be archived instead. Returns FAILED_PRECONDITION (ErrFormHasSubmissions) otherwise.
2.11 List forms
GET /api/v1/crm/forms?status=published&name=KYC+Verification&page=1&per_page=50
Results are ordered by name ASC, version DESC — the latest version of each family appears first.
2.12 Submit a form
POST /api/v1/crm/forms/{form_definition_id}/submissions
Authorization: Bearer {user_jwt}
Content-Type: application/json
Request:
{
"form_definition_id": 10,
"contact_id": 501,
"values": {
"id_type": "passport",
"id_number": "AB123456",
"id_scan": ["upload_7f3a1b2c"],
"segment": "enterprise"
}
}
| Field | Required | Notes |
|---|---|---|
form_definition_id | yes | Must match the path parameter. Form must be published |
contact_id | yes* | *Exactly one of contact_id / account_id must be set |
account_id | yes* | |
context_type | no | Polymorphic source tag, e.g. "pipeline_run_action" (up to 40 chars) |
context_id | no | Source ID within context_type |
values | yes | Keys must match form field keys. Required fields must be present |
Response (200):
{
"data": {
"id": 99,
"organization_id": 7,
"form_definition_id": 10,
"form_version": 1,
"schema_snapshot": { "fields": [...], "write_back": [...] },
"values": {
"id_type": "passport",
"id_number": "AB123456",
"id_scan": ["upload_7f3a1b2c"],
"segment": "enterprise"
},
"contact_id": 501,
"account_id": 0,
"context_type": "",
"context_id": 0,
"write_back_result": [
{
"target": "contact",
"field_key": "segment",
"custom_field_definition_id": 43,
"status": "applied",
"old_value": null,
"new_value": "enterprise"
}
],
"submitted_by_user_id": 17,
"submitted_at": "2026-06-08T11:00:00Z"
}
}
write_back_result[].status values: applied, skipped (field archived or no subject matched), failed (value failed custom-field validation). Write-back failures do not roll back the submission.
2.13 Retrieve and list submissions
| Method | Path | Description |
|---|---|---|
GET | /api/v1/crm/form-submissions/{id} | Get a single submission (includes schema snapshot) |
GET | /api/v1/crm/form-submissions | Paginated list. Filters: form_definition_id=, contact_id=, account_id=, context_type=, context_id= |
DELETE | /api/v1/crm/form-submissions/{id} | Soft-delete (retained for audit; excluded from list results) |
3. Deals & Deal Stages
Deals represent sales opportunities. Each deal is linked to at least one contact or account and moves through a configurable set of pipeline stages.
3.1 Default deal stages
Six stages are seeded automatically when an organisation is created:
sort_order | Name | Default probability |
|---|---|---|
| 0 | Prospecting | 10% |
| 1 | Qualification | 20% |
| 2 | Proposal | 50% |
| 3 | Negotiation | 75% |
| 4 | Closed Won | 100% |
| 5 | Closed Lost | 0% |
These can be renamed, reordered, or replaced. You can also create entirely new stages.
3.2 Manage deal stages
POST /api/v1/crm/deal-stages
Authorization: Bearer {user_jwt}
Content-Type: application/json
Request:
{
"name": "Proof of Concept",
"description": "Customer is running a technical evaluation",
"sort_order": 2,
"probability": 40
}
| Field | Required | Notes |
|---|---|---|
name | yes | Must be non-empty |
probability | yes | Integer 0–100. Copied to a deal when the stage is assigned |
description | no | Human context for the stage |
sort_order | no | Lower values appear first; ties broken by creation time |
Other stage endpoints:
| Method | Path | Description |
|---|---|---|
GET | /api/v1/crm/deal-stages/{id} | Get a single stage |
GET | /api/v1/crm/deal-stages | List all active stages, sorted by sort_order ASC |
PATCH | /api/v1/crm/deal-stages/{id} | Update name, description, probability. Use ReorderDealStages to change ordering |
DELETE | /api/v1/crm/deal-stages/{id} | Soft-delete. Fails with FAILED_PRECONDITION (ErrDealStageInUse) if the stage has open deals |
POST | /api/v1/crm/deal-stages/reorder | Atomic batch reorder |
Reorder stages
POST /api/v1/crm/deal-stages/reorder
Content-Type: application/json
{
"stages": [
{ "id": 1, "sort_order": 0 },
{ "id": 3, "sort_order": 1 },
{ "id": 2, "sort_order": 2 }
]
}
Stages not included retain their current sort_order.
3.3 Create a deal
POST /api/v1/crm/deals
Authorization: Bearer {user_jwt}
Content-Type: application/json
Request:
{
"title": "Acme Corp — Enterprise Plan",
"description": "Annual contract renewal + upsell to 500 seats",
"stage_id": 2,
"contact_id": 501,
"account_id": 101,
"owner_id": 17,
"currency": "USD",
"probability": 50,
"expected_close_date": "2026-09-30T00:00:00Z"
}
At least one of contact_id or account_id must be supplied. The caller must have write permission on the referenced parent object.
| Field | Required | Notes |
|---|---|---|
title | yes | 1–255 chars |
contact_id or account_id | yes (one or both) | At least one is mandatory |
stage_id | no | Leave unset to create an un-staged deal |
owner_id | no | Defaults to the authenticated user |
currency | no | ISO 4217 code, e.g. "USD" |
probability | no | 0–100. Defaults to the stage's default probability if stage_id is set |
expected_close_date | no | Used for pipeline forecasting |
source_contact_id | no | Links this deal to the originating lead contact (e.g. from ConvertLead) |
3.4 The Deal object
{
"id": 201,
"organization_id": 7,
"title": "Acme Corp — Enterprise Plan",
"description": "Annual contract renewal + upsell to 500 seats",
"stage_id": 2,
"contact_id": 501,
"account_id": 101,
"owner_id": 17,
"currency": "USD",
"value": 24000.00,
"probability": 50,
"expected_close_date": "2026-09-30T00:00:00Z",
"outcome": "",
"close_reason": "",
"closed_at": null,
"closed_by": null,
"created_by": 17,
"source_contact_id": 0,
"line_items": [],
"created_at": "2026-06-08T10:00:00Z",
"updated_at": "2026-06-08T10:00:00Z"
}
value is read-only — it is automatically computed as SUM(quantity × unit_price) across all line items. outcome is "" (open), "won", or "lost".
Note:
line_itemsis populated only onGetDeal; it is empty in list responses for performance.
3.5 Line items
Line items are the products or services in a deal. Their quantities and unit prices drive the deal's value.
POST /api/v1/crm/deals/{deal_id}/line-items
Content-Type: application/json
{
"name": "Enterprise Licence — 500 seats",
"description": "Annual seat licence",
"quantity": 500,
"unit_price": 48.00,
"sort_order": 1
}
Response includes the new LineItem and the deal's updated value (500 × 48.00 = 24000.00).
| Method | Path | Description |
|---|---|---|
GET | /api/v1/crm/deals/{deal_id}/line-items | List all line items, ordered by sort_order ASC |
PATCH | /api/v1/crm/deals/{deal_id}/line-items/{item_id} | Update a line item. Deal value is recomputed |
DELETE | /api/v1/crm/deals/{deal_id}/line-items/{item_id} | Hard delete (not soft-deleted). Deal value is recomputed |
3.6 Update a deal
PATCH /api/v1/crm/deals/{id}
Content-Type: application/json
All fields are optional. Supply only the fields you want to change. To move a deal to a different stage, set stage_id. To un-stage a deal, omit stage_id (proto3 optional semantics handle the nil case server-side).
3.7 Close a deal
Deals have two terminal transitions: won and lost. Both require a non-empty close_reason.
POST /api/v1/crm/deals/{id}/won
Content-Type: application/json
{ "close_reason": "Signed 12-month contract" }
POST /api/v1/crm/deals/{id}/lost
Content-Type: application/json
{ "close_reason": "Budget cut — revisit Q1 2027" }
Both transitions:
- Set
outcome("won"or"lost"),closed_at, andclosed_by - Write a
crm.deal.won/crm.deal.lostactivity entry on the parent contact/account - Are terminal — a closed deal cannot be re-opened or re-closed (
FAILED_PRECONDITION)
3.8 List and filter deals
GET /api/v1/crm/deals?page=1&per_page=50
Filter query parameters (all optional):
| Parameter | Description |
|---|---|
filter.contact_id | Only deals linked to this contact |
filter.account_id | Only deals linked to this account |
filter.stage_ids | Comma-separated list of stage IDs |
filter.owner_id | Only deals owned by this user |
filter.outcome | "" (all), "open", "won", or "lost" |
filter.expected_close_from | ISO 8601 datetime |
filter.expected_close_to | ISO 8601 datetime |
filter.value_min | Minimum deal value |
filter.value_max | Maximum deal value |
filter.source_contact_id | Deals sourced from this lead contact |
Defaults to all deals (open + closed), ordered by created_at DESC, 50 per page.
4. Pipeline Definitions
Pipelines are versioned, multi-stage workflow templates that drive structured processes for contacts or deals — KYC, onboarding sequences, deal qualification, etc. A pipeline definition describes the template; actual executions are tracked as Pipeline Runs (§5).
4.1 Subject types
subject_type | Enrollment API | Auto-enroll support |
|---|---|---|
contact | POST /api/v1/crm/contacts/{contact_id}/pipeline-runs | Yes — via auto_enroll_contact_stage_id |
deal | POST /api/v1/crm/deals/{deal_id}/pipeline-runs | No |
subject_type is immutable across versions — a contact pipeline can never be re-published as a deal pipeline.
4.2 Schema structure
The pipeline schema is a JSON object with this shape:
{
"entry_stage_key": "intake",
"stages": [
{
"key": "intake",
"name": "Intake",
"terminal": false,
"actions": [
{
"key": "kyc_form",
"type": "form",
"required": true,
"config": {
"form_definition_id": 10
}
},
{
"key": "verify_checklist",
"type": "checklist",
"required": true,
"config": {
"items": [
{ "key": "id_verified", "title": "ID document verified" },
{ "key": "address_verified", "title": "Address verified" }
]
}
}
],
"branches": [
{
"expression": "{{kyc_form.id_type}} == 'passport'",
"target": "passport_review"
},
{
"expression": "default",
"target": "standard_review"
}
]
},
{
"key": "passport_review",
"name": "Passport Review",
"terminal": false,
"actions": [
{
"key": "create_deal_from_kyc",
"type": "create_deal",
"required": false,
"config": {
"title_template": "KYC Deal — {{kyc_form.id_number}}",
"deal_stage_id": 1
}
}
],
"branches": [
{ "expression": "default", "target": "completed" }
]
},
{
"key": "standard_review",
"name": "Standard Review",
"terminal": false,
"actions": [],
"branches": [
{ "expression": "default", "target": "completed" }
]
},
{
"key": "completed",
"name": "Completed",
"terminal": true,
"actions": [],
"branches": []
}
]
}
Schema rules
entry_stage_keymust reference an existing stage key.- Stage keys match
^[a-z][a-z0-9_]{0,99}$and must be unique across the pipeline. - Action keys match the same pattern and must be unique within their stage.
- Every non-terminal stage must have at least one branch. The last branch must use the expression
"default". - Branch targets must reference existing stage keys. The stage graph must be acyclic and fully reachable from
entry_stage_key.
4.3 Action types
type | When it runs | Config fields |
|---|---|---|
form | On stage entry — materialises an action row the agent fills out via SubmitForm | form_definition_id (required; form must be published) |
checklist | On stage entry — materialises a checklist the agent ticks off | items: ordered list of { "key": "...", "title": "..." } |
create_deal | Runs automatically when the stage is entered | title_template, amount_var (optional variable reference), deal_stage_id |
send_notification | Runs automatically when the stage is entered | recipient, template_id |
required: true means the action must complete successfully before the stage can advance. A non-required action failure does not fail the run.
4.4 Variables and interpolation
As actions complete, their results are merged into the run's variables bag under the action's key:
{
"kyc_form": {
"id_type": "passport",
"id_number": "AB123456",
"segment": "enterprise"
},
"verify_checklist": {
"id_verified": true,
"address_verified": true
}
}
Branch expressions and action config templates interpolate from this bag using {{action_key.path}} syntax:
"{{kyc_form.id_type}} == 'passport'"
"title_template": "KYC Deal — {{kyc_form.id_number}}"
4.5 Create a pipeline
POST /api/v1/crm/pipelines
Authorization: Bearer {user_jwt}
Content-Type: application/json
Request:
{
"name": "KYC Onboarding",
"description": "End-to-end KYC verification for new contacts",
"subject_type": "contact",
"schema": { ... },
"auto_enroll_contact_stage_id": 0
}
| Field | Required | Notes |
|---|---|---|
name | yes | 1–255 chars. Immutable across versions |
subject_type | yes | contact or deal. Immutable across versions |
schema | yes | Full stage/action/branch graph. Validated on creation |
description | no | Human-readable summary |
auto_enroll_contact_stage_id | no | Contact-subject only. Set to a contact stage ID to auto-enroll contacts when they enter that stage. 0 = disabled |
The pipeline is created in draft status.
4.6 Publish a pipeline
POST /api/v1/crm/pipelines/{id}/publish
Re-validates the full schema ruleset (stage graph, action config requirements, referenced form publication status) and flips status to published. Enrollments are now accepted.
4.7 Versioning workflow
| Step | Action |
|---|---|
| 1 | Fork a new draft: POST /api/v1/crm/pipelines/{id}/drafts (bumps version, status = draft) |
| 2 | Edit the draft: PATCH /api/v1/crm/pipelines/{id} (description, schema, auto-enroll trigger) |
| 3 | Publish: POST /api/v1/crm/pipelines/{id}/publish |
In-flight runs against older versions continue against their definition snapshot — they are unaffected by new versions.
4.8 Archive and delete
| Action | Description |
|---|---|
POST /api/v1/crm/pipelines/{id}/archive | Transitions to archived. New enrollments rejected; in-flight runs continue. Idempotent |
DELETE /api/v1/crm/pipelines/{id} | Soft-deletes a draft or archived pipeline. Fails with FAILED_PRECONDITION (ErrPipelineHasRuns) if the pipeline has ever been enrolled |
4.9 List pipelines
GET /api/v1/crm/pipelines?status=published&subject_type=contact&name=KYC+Onboarding
Ordered by name ASC, version DESC. Pagination via page and per_page.
5. Pipeline Runs
A pipeline run is one execution of a pipeline definition against a single subject (a contact or deal). Each run tracks the current stage, an accumulating variables bag, and an ordered timeline of materialised actions.
5.1 Run status values
| Status | Description |
|---|---|
running | Actively progressing. Pending actions await completion |
paused | Frozen by PausePipelineRun. No transitions until resumed |
completed | Terminal — run reached a stage with terminal: true |
cancelled | Terminal — manually cancelled. cancel_reason is populated |
failed | Terminal — an action failed unrecoverably. failure_reason is populated |
5.2 Enroll a contact
POST /api/v1/crm/contacts/{contact_id}/pipeline-runs
Authorization: Bearer {user_jwt}
Content-Type: application/json
{
"pipeline_definition_id": 5
}
The pipeline's subject_type must be "contact". If the contact already has a running or paused run for this pipeline, enrollment returns FAILED_PRECONDITION (ErrPipelineAlreadyEnrolled). Terminal runs (completed, cancelled, failed) do not block re-enrollment.
Response (200) returns a PipelineRun object (see §5.4).
5.3 Enroll a deal
POST /api/v1/crm/deals/{deal_id}/pipeline-runs
Authorization: Bearer {user_jwt}
Content-Type: application/json
{
"pipeline_definition_id": 8
}
The pipeline's subject_type must be "deal".
5.4 The PipelineRun object
{
"id": 301,
"organization_id": 7,
"pipeline_definition_id": 5,
"definition_version": 2,
"subject_type": "contact",
"subject_id": 501,
"status": "running",
"current_stage_key": "intake",
"variables": {},
"enrolled_by_user_id": 17,
"enroll_source": "manual",
"cancel_reason": "",
"failure_reason": "",
"paused_at": null,
"started_at": "2026-06-08T11:00:00Z",
"completed_at": null,
"cancelled_at": null,
"failed_at": null
}
| Field | Description |
|---|---|
definition_version | The pipeline version pinned at enrollment. Edits to later versions do not affect this run |
current_stage_key | Stage the run is currently in |
variables | {action_key: result} bag accumulated as actions complete. Drives branch evaluation |
enroll_source | "manual" (user called Enroll) or "auto" (triggered by a contact stage transition) |
5.5 Retrieve and list runs
| Method | Path | Description |
|---|---|---|
GET | /api/v1/crm/pipeline-runs/{id} | Get a single run |
GET | /api/v1/crm/pipeline-runs | Paginated list. Filters: pipeline_definition_id=, subject_type=, subject_id=, status= |
5.6 Action timeline
Each materialised action in a run becomes a PipelineRunAction row. Query the full ordered timeline:
GET /api/v1/crm/pipeline-runs/{run_id}/actions
Each row includes:
{
"id": 1001,
"pipeline_run_id": 301,
"stage_key": "intake",
"action_key": "kyc_form",
"action_type": "form",
"required": true,
"status": "completed",
"form_submission_id": 99,
"deal_id": 0,
"engagement_id": 0,
"result": {
"id_type": "passport",
"id_number": "AB123456"
},
"completed_by_user_id": 17,
"completed_at": "2026-06-08T11:10:00Z",
"error_message": ""
}
| Field | Description |
|---|---|
status | pending, in_progress, completed, skipped, failed |
form_submission_id | Populated when action_type = "form" and the action completed |
deal_id | Populated when action_type = "create_deal" and the deal was created |
engagement_id | Populated when action_type = "send_notification" |
result | Snapshot of what the engine wrote into run.variables under this action's key |
Stage progression
When all required actions in the current stage complete, the engine evaluates the stage's branches in order. The first truthy branch wins; "default" is the fallback. The run transitions to the target stage and that stage's actions materialise.
5.7 Cancel a run
POST /api/v1/crm/pipeline-runs/{id}/cancel
Content-Type: application/json
{ "reason": "Customer withdrew application" }
reason is required (min 1 char). Pending action rows are marked skipped. The run transitions to cancelled.
5.8 Pause and resume
Pause — freezes a running run. No new actions materialise; pending actions that complete record their results but stage transitions are deferred.
POST /api/v1/crm/pipeline-runs/{id}/pause
Resume — lifts the pause and re-evaluates the current stage against the current variables.
POST /api/v1/crm/pipeline-runs/{id}/resume
5.9 Restart from a stage
Rewinds the run to a given stage, clearing every action that materialised at or after that stage (and removing the variables they contributed). The target stage's actions are re-materialised and the run re-enters running status.
POST /api/v1/crm/pipeline-runs/{id}/restart
Content-Type: application/json
{ "from_stage_key": "intake" }
from_stage_key must reference a stage present in the run's pinned definition snapshot. Works from any run status — terminal runs (completed, cancelled, failed) are revived.
6. Auto-enrollment
For contact-subject pipelines, you can trigger automatic enrollment whenever a contact enters a specific stage.
When creating or updating a pipeline definition, set auto_enroll_contact_stage_id to a contact stage ID:
{
"auto_enroll_contact_stage_id": 3
}
When a contact transitions into that stage, the pipeline engine automatically enrolls them. Auto-enrolled runs are recorded with enroll_source: "auto". The idempotency constraint still applies — if the contact already has an open run for this pipeline, the auto-enrollment is silently skipped.
Set auto_enroll_contact_stage_id = 0 to disable auto-enrollment. This field must always be 0 on deal-subject pipelines.
7. End-to-end example: KYC pipeline for contacts
This walkthrough shows how all the pieces fit together.
Step 1 — Define custom fields
POST /api/v1/crm/custom-fields
{ "object_type": "contact", "key": "kyc_status", "label": "KYC Status",
"field_type": "picklist", "picklist_options": {"options": [
{"value": "pending", "label": "Pending"},
{"value": "approved", "label": "Approved"},
{"value": "rejected", "label": "Rejected"}
]}, "section": "Compliance" }
Step 2 — Create and publish the form
POST /api/v1/crm/forms
{ "name": "KYC Verification", "schema": { "fields": [...], "write_back": [
{ "field_key": "kyc_status", "target": "contact", "custom_field_definition_id": 42 }
]}}
POST /api/v1/crm/forms/10/publish
Step 3 — Create and publish the pipeline
POST /api/v1/crm/pipelines
{
"name": "KYC Onboarding", "subject_type": "contact",
"auto_enroll_contact_stage_id": 3,
"schema": {
"entry_stage_key": "review",
"stages": [
{
"key": "review", "name": "Review", "terminal": false,
"actions": [{ "key": "kyc_form", "type": "form", "required": true, "config": { "form_definition_id": 10 }}],
"branches": [
{ "expression": "{{kyc_form.kyc_status}} == 'approved'", "target": "done" },
{ "expression": "default", "target": "rejected" }
]
},
{ "key": "done", "name": "Approved", "terminal": true, "actions": [], "branches": [] },
{ "key": "rejected", "name": "Rejected", "terminal": true, "actions": [], "branches": [] }
]
}
}
POST /api/v1/crm/pipelines/5/publish
Step 4 — A contact enters the auto-enroll stage
The contact moves into contact stage ID 3. The pipeline engine auto-enrolls them, creating a run (status: running, current_stage_key: "review") and materialising the kyc_form action.
Step 5 — Agent submits the form
POST /api/v1/crm/forms/10/submissions
{
"form_definition_id": 10, "contact_id": 501,
"context_type": "pipeline_run_action", "context_id": 1001,
"values": { "kyc_status": "approved", "id_type": "passport", "id_number": "AB123456" }
}
The write-back rule fires: contact.custom_fields.kyc_status is set to "approved".
Step 6 — Pipeline advances
The engine evaluates the review stage's branches against the updated variables. {{kyc_form.kyc_status}} == 'approved' is truthy, so the run transitions to the done stage (terminal). The run status becomes completed.
8. Complete API reference
Custom Fields
| Method | Path | Description |
|---|---|---|
POST | /api/v1/crm/custom-fields | Create a custom field definition |
GET | /api/v1/crm/custom-fields/{id} | Get a definition |
GET | /api/v1/crm/custom-fields | List definitions (object_type=, section=, include_archived=) |
PATCH | /api/v1/crm/custom-fields/{id} | Update label, picklist options, validation rules, section, position |
POST | /api/v1/crm/custom-fields/{id}:archive | Archive a field |
POST | /api/v1/crm/custom-fields:reorder | Batch reorder |
Custom Forms
| Method | Path | Description |
|---|---|---|
POST | /api/v1/crm/forms | Create form definition (draft) |
GET | /api/v1/crm/forms/{id} | Get a form |
GET | /api/v1/crm/forms | List forms (status=, name=) |
PATCH | /api/v1/crm/forms/{id} | Update draft (description, schema) |
POST | /api/v1/crm/forms/{id}/publish | Publish a draft |
POST | /api/v1/crm/forms/{id}/drafts | Fork published form into new draft |
POST | /api/v1/crm/forms/{id}/archive | Archive a form |
DELETE | /api/v1/crm/forms/{id} | Delete draft / archived form (no submissions) |
POST | /api/v1/crm/forms/{form_definition_id}/submissions | Submit a form |
GET | /api/v1/crm/form-submissions/{id} | Get a submission |
GET | /api/v1/crm/form-submissions | List submissions |
DELETE | /api/v1/crm/form-submissions/{id} | Soft-delete a submission |
Deals & Stages
| Method | Path | Description |
|---|---|---|
POST | /api/v1/crm/deal-stages | Create deal stage |
GET | /api/v1/crm/deal-stages/{id} | Get deal stage |
GET | /api/v1/crm/deal-stages | List deal stages (sorted by sort_order) |
PATCH | /api/v1/crm/deal-stages/{id} | Update name, description, probability |
DELETE | /api/v1/crm/deal-stages/{id} | Soft-delete (fails if stage has open deals) |
POST | /api/v1/crm/deal-stages/reorder | Atomic batch reorder |
POST | /api/v1/crm/deals | Create deal |
GET | /api/v1/crm/deals/{id} | Get deal (includes line items) |
GET | /api/v1/crm/deals | List deals (filters: contact, account, stage, owner, outcome, value, dates) |
PATCH | /api/v1/crm/deals/{id} | Update deal |
POST | /api/v1/crm/deals/{id}/won | Close deal as won |
POST | /api/v1/crm/deals/{id}/lost | Close deal as lost |
DELETE | /api/v1/crm/deals/{id} | Soft-delete deal and line items |
POST | /api/v1/crm/deals/{deal_id}/line-items | Add line item |
GET | /api/v1/crm/deals/{deal_id}/line-items | List line items |
PATCH | /api/v1/crm/deals/{deal_id}/line-items/{item_id} | Update line item |
DELETE | /api/v1/crm/deals/{deal_id}/line-items/{item_id} | Hard-delete line item |
Pipeline Definitions
| Method | Path | Description |
|---|---|---|
POST | /api/v1/crm/pipelines | Create pipeline definition (draft) |
GET | /api/v1/crm/pipelines/{id} | Get a pipeline |
GET | /api/v1/crm/pipelines | List pipelines (status=, name=, subject_type=) |
PATCH | /api/v1/crm/pipelines/{id} | Update draft (description, schema, auto-enroll trigger) |
POST | /api/v1/crm/pipelines/{id}/publish | Publish a draft |
POST | /api/v1/crm/pipelines/{id}/drafts | Fork published pipeline into new draft |
POST | /api/v1/crm/pipelines/{id}/archive | Archive a pipeline |
DELETE | /api/v1/crm/pipelines/{id} | Delete draft / archived pipeline (no runs) |
Pipeline Runs
| Method | Path | Description |
|---|---|---|
POST | /api/v1/crm/contacts/{contact_id}/pipeline-runs | Enroll a contact |
POST | /api/v1/crm/deals/{deal_id}/pipeline-runs | Enroll a deal |
GET | /api/v1/crm/pipeline-runs/{id} | Get a run |
GET | /api/v1/crm/pipeline-runs | List runs (pipeline_definition_id=, subject_type=, subject_id=, status=) |
GET | /api/v1/crm/pipeline-runs/{run_id}/actions | List action timeline for a run |
POST | /api/v1/crm/pipeline-runs/{id}/cancel | Cancel a run |
POST | /api/v1/crm/pipeline-runs/{id}/pause | Pause a running run |
POST | /api/v1/crm/pipeline-runs/{id}/resume | Resume a paused run |
POST | /api/v1/crm/pipeline-runs/{id}/restart | Restart from a stage key |