WarmblyDocs
Endpoint reference

CRM

Manage pipelines, stages, deals, task types, and CRM tasks for your organization, and run the CRM on HubSpot or Pipedrive.

The CRM endpoints model your sales pipeline: deals move through stages of a pipeline, and CRM tasks track the follow-up work attached to contacts and deals. Every route is organization-scoped and requires an active organization on the session or API key. Read access uses the CRM read scope (READ_CRM / org permission view_contacts), and mutations use the CRM write scope (WRITE_CRM / org permission manage_contacts), except team membership operations which gate on manage_team. See authentication and permissions for how scopes map to API keys and member roles.

Every list endpoint returns a data array plus the standard pagination envelope with an opaque next_cursor. The simple GET list endpoints use a keyset cursor; the faceted POST .../search endpoints paginate by offset under the hood (their nullable sort columns rule out a keyset cursor) but expose the same opaque next_cursor, and add an exact total.

List pipelines

GET /crm/pipelines

Return every pipeline in the organization, each with its ordered stages.

Auth: Scope READ_CRM · Org permission view_contacts

Response

Returns a bare array of pipeline objects (not wrapped in an envelope).

[
  {
    "id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
    "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
    "name": "Sales",
    "position": 0,
    "stages": [
      {
        "id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
        "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
        "name": "Lead",
        "color": "#0ea5e9",
        "position": 0,
        "deal_count": 12,
        "created_at": "2026-05-01T09:00:00Z",
        "updated_at": "2026-05-01T09:00:00Z"
      }
    ],
    "created_at": "2026-05-01T09:00:00Z",
    "updated_at": "2026-05-01T09:00:00Z"
  }
]

Create pipeline

POST /crm/pipelines

Create a pipeline, optionally seeding it with an ordered set of stages.

Auth: Scope WRITE_CRM · Org permission manage_contacts

Request body

FieldTypeRequiredDescription
namestringyesPipeline name (1 to 255 characters).
stagesarraynoStages to create with the pipeline, in order.
stages[].namestringyesStage name (1 to 255 characters).
stages[].colorstringyesStage color (hex).
{
  "name": "Sales",
  "stages": [
    { "name": "Lead", "color": "#0ea5e9" },
    { "name": "Qualified", "color": "#8b5cf6" },
    { "name": "Won", "color": "#22c55e" }
  ]
}

Response

201 Created with the created pipeline object (same shape as in the list response, including its stages).

{
  "id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "name": "Sales",
  "position": 0,
  "stages": [
    {
      "id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
      "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
      "name": "Lead",
      "color": "#0ea5e9",
      "position": 0,
      "created_at": "2026-06-12T10:00:00Z",
      "updated_at": "2026-06-12T10:00:00Z"
    }
  ],
  "created_at": "2026-06-12T10:00:00Z",
  "updated_at": "2026-06-12T10:00:00Z"
}

Get pipeline

GET /crm/pipelines/:id

Fetch a single pipeline with its stages.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
idpathuuidPipeline ID.

Response

Returns the pipeline object (same shape as a list item).

{
  "id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "name": "Sales",
  "position": 0,
  "stages": [],
  "created_at": "2026-05-01T09:00:00Z",
  "updated_at": "2026-05-01T09:00:00Z"
}

Update pipeline

PATCH /crm/pipelines/:id

Rename a pipeline. Only the name can be changed here; stages have their own endpoints.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidPipeline ID.

Request body

FieldTypeRequiredDescription
namestringnoNew pipeline name.
{
  "name": "Enterprise Sales"
}

Response

Returns the updated pipeline object.

Delete pipeline

DELETE /crm/pipelines/:id

Delete a pipeline and its stages.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidPipeline ID.

Response

204 No Content.

Create stage

POST /crm/pipelines/:id/stages

Append a stage to a pipeline.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidPipeline ID.

Request body

FieldTypeRequiredDescription
namestringyesStage name (1 to 255 characters).
colorstringyesStage color (hex).
{
  "name": "Negotiation",
  "color": "#f59e0b"
}

Response

201 Created with the created stage object.

{
  "id": "9c0d1e2f-3a4b-4c5d-6e7f-8a9b0c1d2e3f",
  "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
  "name": "Negotiation",
  "color": "#f59e0b",
  "position": 3,
  "created_at": "2026-06-12T10:05:00Z",
  "updated_at": "2026-06-12T10:05:00Z"
}

Update stage

PATCH /crm/pipelines/:id/stages/:stageId

Rename or recolor a stage.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidPipeline ID.
stageIdpathuuidStage ID.

Request body

FieldTypeRequiredDescription
namestringnoNew stage name.
colorstringnoNew stage color (hex).
{
  "name": "Contract Sent",
  "color": "#6366f1"
}

Response

Returns the updated stage object.

Delete stage

DELETE /crm/pipelines/:id/stages/:stageId

Remove a stage from a pipeline.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidPipeline ID.
stageIdpathuuidStage ID.

Response

204 No Content.

List deals

GET /crm/deals

List deals with optional pipeline, stage, and status filters, keyset-paginated.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
pipeline_idqueryuuidRestrict to deals in this pipeline.
stage_idqueryuuidRestrict to deals in this stage.
statusquerystringRestrict to open, won, or lost.
cursorquerystringOpaque cursor from a previous page's pagination.next_cursor.
limitqueryintPage size, 1 to 100 (default 50).

Response

A data array of deal objects plus a keyset pagination envelope. List rows may include joined contact and stage objects and a campaign_name.

{
  "data": [
    {
      "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
      "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
      "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
      "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811",
      "name": "Acme renewal",
      "value": 12000,
      "currency": "USD",
      "status": "open",
      "expected_close_date": "2026-07-15T00:00:00Z",
      "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344",
      "campaign_id": "cc33dd44-ee55-4ff6-9700-112233445566",
      "source_mailbox_id": "dd44ee55-ff66-4007-8811-223344556677",
      "created_at": "2026-06-01T12:00:00Z",
      "updated_at": "2026-06-10T09:30:00Z",
      "campaign_name": "Q2 outbound"
    }
  ],
  "pagination": {
    "total": null,
    "next_cursor": "c1_b3BhcXVlLWN1cnNvcg",
    "has_more": true
  }
}

Create deal

POST /crm/deals

Create a deal in a pipeline stage, optionally linked to a contact and attributed to a campaign and source mailbox.

Auth: Scope WRITE_CRM · Org permission manage_contacts

Request body

FieldTypeRequiredDescription
pipeline_iduuidyesPipeline the deal belongs to.
stage_iduuidyesInitial stage.
contact_iduuidnoLinked contact.
namestringyesDeal name (1 to 255 characters).
valuenumbernoMonetary value.
currencystringnoISO currency code.
expected_close_datestring (date-time)noExpected close date.
assigned_touuidnoOwner (org member user ID).
campaign_iduuidnoAttributed campaign.
source_mailbox_iduuidnoSending mailbox that produced the originating reply.
{
  "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
  "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
  "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811",
  "name": "Acme renewal",
  "value": 12000,
  "currency": "USD",
  "expected_close_date": "2026-07-15T00:00:00Z",
  "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344"
}

Response

201 Created with the created deal object. New deals default to status: "open".

{
  "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
  "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
  "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811",
  "name": "Acme renewal",
  "value": 12000,
  "currency": "USD",
  "status": "open",
  "expected_close_date": "2026-07-15T00:00:00Z",
  "created_at": "2026-06-12T10:10:00Z",
  "updated_at": "2026-06-12T10:10:00Z"
}

Search deals

POST /crm/deals/search

Faceted, server-paginated deal search. Every filter is optional; an empty body matches every deal in the organization. Filters are sent in the JSON body, while limit and cursor are query params.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
limitqueryintPage size, 1 to 200 (default 50).
cursorquerystringOpaque cursor from a previous page's pagination.next_cursor. Omit for the first page.

Request body

FieldTypeRequiredDescription
querystringnoCase-insensitive match on deal name.
statusesstring[]noAny of open, won, lost.
pipeline_idsstring[]noRestrict to any of these pipelines.
stage_idsstring[]noRestrict to any of these stages.
assigned_tostring[]noOwner is any of these user IDs.
campaign_idsstring[]noAttributed campaign is any of these.
min_valuenumbernoValue greater than or equal to.
max_valuenumbernoValue less than or equal to.
close_afterstring (date-time)noExpected close date on or after.
close_beforestring (date-time)noExpected close date on or before.
created_afterstring (date-time)noCreated on or after.
created_beforestring (date-time)noCreated on or before.
sort_bystringnoOne of created_at, updated_at, value, expected_close_date, name.
reversebooleannotrue sorts ascending, false (default) descending.
{
  "query": "renewal",
  "statuses": ["open"],
  "pipeline_ids": ["5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f"],
  "min_value": 1000,
  "sort_by": "value",
  "reverse": false
}

Response

A data array of deal objects (with joined contact, stage, and campaign_name) plus the standard pagination envelope (opaque next_cursor) with an exact total.

{
  "data": [
    {
      "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
      "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
      "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
      "name": "Acme renewal",
      "value": 12000,
      "currency": "USD",
      "status": "open",
      "created_at": "2026-06-01T12:00:00Z",
      "updated_at": "2026-06-10T09:30:00Z"
    }
  ],
  "pagination": {
    "total": 137,
    "next_cursor": "o1_NTA",
    "has_more": true
  }
}

Deals summary

POST /crm/deals/summary

Aggregate counts and value sums over the same filter body as deal search, so header totals and per-stage board column totals reflect the whole matching set rather than a single page.

Auth: Scope READ_CRM · Org permission view_contacts

Request body

Identical to search deals. All facets are optional; an empty body summarizes every deal in the organization.

{
  "pipeline_ids": ["5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f"],
  "statuses": ["open", "won"]
}

Response

Returns the aggregate object. stages holds a per-stage count and open-deal value. mixed_currency is true when matching deals span more than one currency, in which case the top-level value sums should be treated as approximate.

{
  "total": 42,
  "open_count": 30,
  "open_value": 415000,
  "won_count": 9,
  "won_value": 220000,
  "lost_count": 3,
  "lost_value": 0,
  "currency": "USD",
  "stages": [
    {
      "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
      "count": 18,
      "value": 240000
    }
  ],
  "mixed_currency": false
}

Get deal

GET /crm/deals/:id

Fetch a single deal.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
idpathuuidDeal ID.

Response

Returns the deal object. Joined contact, stage, and campaign_name are populated by the list and search queries, not by this single-row read.

{
  "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f",
  "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607",
  "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811",
  "name": "Acme renewal",
  "value": 12000,
  "currency": "USD",
  "status": "open",
  "expected_close_date": "2026-07-15T00:00:00Z",
  "created_at": "2026-06-01T12:00:00Z",
  "updated_at": "2026-06-10T09:30:00Z"
}

Update deal

PATCH /crm/deals/:id

Update a deal. Moving it to a different stage_id records a stage-change activity, and setting status to won or lost stamps the corresponding close timestamp.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidDeal ID.

Request body

FieldTypeRequiredDescription
stage_iduuidnoMove the deal to this stage.
contact_iduuidnoLinked contact.
namestringnoDeal name.
valuenumbernoMonetary value.
currencystringnoISO currency code.
statusstringnoOne of open, won, lost.
expected_close_datestring (date-time)noExpected close date.
lost_reasonstringnoReason recorded when marking lost.
assigned_touuidnoOwner (org member user ID).
{
  "stage_id": "9c0d1e2f-3a4b-4c5d-6e7f-8a9b0c1d2e3f",
  "status": "won"
}

Response

Returns the updated deal object.

Delete deal

DELETE /crm/deals/:id

Delete a deal.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidDeal ID.

Response

204 No Content.

List task types

GET /crm/task-types

List the organization's CRM task types (the kinds of work a task represents, such as Call, Email, or Meeting). A default set is seeded the first time an org lists its types.

Auth: Scope READ_CRM · Org permission view_contacts

Response

Returns a data array of task type objects (no pagination envelope).

{
  "data": [
    {
      "id": "e1d2c3b4-a5f6-4071-8293-a4b5c6d7e8f9",
      "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "name": "Call",
      "color": "#8b5cf6",
      "position": 0,
      "created_at": "2026-05-01T09:00:00Z",
      "updated_at": "2026-05-01T09:00:00Z"
    }
  ]
}

Create task type

POST /crm/task-types

Create a CRM task type.

Auth: Scope WRITE_CRM · Org permission manage_contacts

Request body

FieldTypeRequiredDescription
namestringyesType name (1 to 60 characters).
colorstringnoType color (hex).
{
  "name": "Demo",
  "color": "#22c55e"
}

Response

201 Created with the created task type object.

{
  "id": "f0e1d2c3-b4a5-4607-8293-a4b5c6d7e8f9",
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "name": "Demo",
  "color": "#22c55e",
  "position": 3,
  "created_at": "2026-06-12T10:20:00Z",
  "updated_at": "2026-06-12T10:20:00Z"
}

Update task type

PATCH /crm/task-types/:id

Rename, recolor, or reorder a task type.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidTask type ID.

Request body

FieldTypeRequiredDescription
namestringnoNew type name.
colorstringnoNew type color (hex).
positionintnoNew ordering position.
{
  "name": "Product demo",
  "position": 1
}

Response

Returns the updated task type object.

Delete task type

DELETE /crm/task-types/:id

Delete a task type. Tasks reference their type by name, so existing tasks keep their label and fall back to a neutral color rather than being orphaned.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidTask type ID.

Response

204 No Content.

List tasks

GET /crm/tasks

List CRM tasks with optional contact, deal, assignee, and status filters, keyset-paginated.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
contact_idqueryuuidRestrict to tasks linked to this contact.
deal_idqueryuuidRestrict to tasks linked to this deal.
assigned_toqueryuuidRestrict to tasks assigned to this user.
statusquerystringOne of pending, in_progress, completed, cancelled.
cursorquerystringOpaque cursor from a previous page's pagination.next_cursor.
limitqueryintPage size, 1 to 100 (default 50).

Response

A data array of task objects plus a keyset pagination envelope.

{
  "data": [
    {
      "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912",
      "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811",
      "deal_id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
      "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344",
      "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344",
      "title": "Send renewal quote",
      "description": "Include the multi-year discount",
      "due_date": "2026-06-20T17:00:00Z",
      "priority": "high",
      "type": "Email",
      "status": "pending",
      "created_at": "2026-06-12T08:00:00Z",
      "updated_at": "2026-06-12T08:00:00Z"
    }
  ],
  "pagination": {
    "total": null,
    "next_cursor": "c1_b3BhcXVlLWN1cnNvcg",
    "has_more": false
  }
}

Create task

POST /crm/tasks

Create a CRM task, optionally linked to a contact and deal and assigned to a user or team.

Auth: Scope WRITE_CRM · Org permission manage_contacts

Request body

FieldTypeRequiredDescription
titlestringyesTask title (1 to 255 characters).
contact_iduuidnoLinked contact.
deal_iduuidnoLinked deal.
assigned_touuidnoAssignee user ID.
assigned_team_iduuidnoAssignee team ID.
descriptionstringnoFree-text description.
due_datestring (date-time)noDue date.
prioritystringnoOne of low, medium, high, urgent.
typestringnoTask type name (matches a configured task type).
{
  "title": "Send renewal quote",
  "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811",
  "deal_id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
  "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344",
  "due_date": "2026-06-20T17:00:00Z",
  "priority": "high",
  "type": "Email"
}

Response

201 Created with the created task object. created_by is set to the authenticated user.

{
  "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912",
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811",
  "deal_id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
  "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344",
  "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344",
  "title": "Send renewal quote",
  "due_date": "2026-06-20T17:00:00Z",
  "priority": "high",
  "type": "Email",
  "status": "pending",
  "created_at": "2026-06-12T10:25:00Z",
  "updated_at": "2026-06-12T10:25:00Z"
}

Search tasks

POST /crm/tasks/search

Faceted, server-paginated task search. Every filter is optional; an empty body matches every task in the organization. Filters are sent in the JSON body, while limit and cursor are query params.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
limitqueryintPage size, 1 to 200 (default 50).
cursorquerystringOpaque cursor from a previous page's pagination.next_cursor. Omit for the first page.

Request body

FieldTypeRequiredDescription
querystringnoCase-insensitive match on task title.
statusesstring[]noAny of pending, in_progress, completed, cancelled.
prioritiesstring[]noAny of low, medium, high, urgent.
typesstring[]noTask type name is any of these.
assigned_tostring[]noAssignee user ID is any of these.
team_idsuuid[]noTask team is any of these, or the assignee belongs to one.
contact_idstringnoLinked contact.
deal_idstringnoLinked deal.
due_afterstring (date-time)noDue on or after.
due_beforestring (date-time)noDue on or before.
overduebooleannoOnly tasks past due and not completed or cancelled.
sort_bystringnoOne of created_at, due_date, priority, title, updated_at.
reversebooleannotrue sorts ascending, false (default) descending.
{
  "statuses": ["pending", "in_progress"],
  "priorities": ["high", "urgent"],
  "overdue": true,
  "sort_by": "due_date",
  "reverse": true
}

Response

A data array of task objects plus the standard pagination envelope (opaque next_cursor) with an exact total.

{
  "data": [
    {
      "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912",
      "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344",
      "title": "Send renewal quote",
      "priority": "high",
      "type": "Email",
      "status": "pending",
      "due_date": "2026-06-20T17:00:00Z",
      "created_at": "2026-06-12T08:00:00Z",
      "updated_at": "2026-06-12T08:00:00Z"
    }
  ],
  "pagination": {
    "total": 64,
    "next_cursor": "o1_NTA",
    "has_more": true
  }
}

Tasks summary

POST /crm/tasks/summary

Aggregate counts over the same filter body as task search, so header totals (by status, overdue, high priority) reflect the whole matching set rather than a single page.

Auth: Scope READ_CRM · Org permission view_contacts

Request body

Identical to search tasks. All facets are optional; an empty body summarizes every task in the organization.

{
  "assigned_to": ["bb22cc33-dd44-4ee5-86ff-770011223344"]
}

Response

Returns the aggregate counts.

{
  "total": 64,
  "pending_count": 28,
  "in_progress_count": 9,
  "completed_count": 22,
  "cancelled_count": 5,
  "overdue_count": 7,
  "high_priority_count": 11
}

Get task

GET /crm/tasks/:id

Fetch a single CRM task.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
idpathuuidTask ID.

Response

Returns the task object (same shape as a list item).

{
  "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912",
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344",
  "title": "Send renewal quote",
  "priority": "high",
  "type": "Email",
  "status": "pending",
  "due_date": "2026-06-20T17:00:00Z",
  "created_at": "2026-06-12T08:00:00Z",
  "updated_at": "2026-06-12T08:00:00Z"
}

Update task

PATCH /crm/tasks/:id

Update a CRM task. Setting status to completed stamps the completion timestamp, and any other status clears it.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidTask ID.

Request body

FieldTypeRequiredDescription
titlestringnoTask title.
assigned_touuidnoAssignee user ID.
assigned_team_iduuidnoAssignee team ID.
descriptionstringnoFree-text description.
due_datestring (date-time)noDue date.
prioritystringnoOne of low, medium, high, urgent.
typestringnoTask type name.
statusstringnoOne of pending, in_progress, completed, cancelled.
{
  "status": "completed"
}

Response

Returns the updated task object.

Delete task

DELETE /crm/tasks/:id

Delete a CRM task.

Auth: Scope WRITE_CRM · Org permission manage_contacts

ParameterInTypeDescription
idpathuuidTask ID.

Response

204 No Content.

Selecting many tasks

The two bulk endpoints below take the same selection, in one of two forms. Either an explicit list of ids:

{ "tasks": ["3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912"] }

Or every task a search matches, minus the ones taken back out:

{
  "all": true,
  "filters": { "statuses": ["pending"], "query": "out_of_office" },
  "exclude": ["3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912"]
}
FieldTypeRequiredDescription
tasksstring[]with all unsetTask ids. At most 1000 per request.
allbooleannoSwitches the selection from tasks to filters.
filtersobjectwith allThe same body search tasks takes, so the set acted on is exactly the set the search returns.
excludestring[]noIds to drop from the resolved set. Ignored unless all is set.

A selection resolving to more than 50,000 tasks is refused with selection_too_large rather than half applied; narrow the filter and run it in parts. A filters block naming something that is not an id in assigned_to, contact_id or deal_id is refused with invalid_filter.

A bulk update raises one crm.task_updated webhook for the whole call rather than one per task, carrying metadata.bulk and metadata.count and no entity_id. A bulk delete raises none, because deleting a task raises none either way.

Neither endpoint takes an Idempotency-Key: both write an end state rather than a delta, so repeating one converges on the same result. They differ in what the repeat reports. A repeated delete finds fewer rows and affected falls to 0. A repeated update still touches every row in the selection, because it stamps updated_at, so affected does not fall; the status and priority simply do not change, and a repeated completion adds no second entry to the contact timeline and does not move completed_at.

Bulk update tasks

PATCH /crm/tasks

Write a status, a priority, or both onto every task in a selection. At least one of the two fields is required; setting status to completed stamps the completion timestamp and records the completion on each linked contact's timeline, exactly as the single-task update does. Any other status clears completed_at, so a task moved back to pending does not keep the time it was finished, again matching the single-task update.

Auth: Scope WRITE_CRM · Org permission manage_contacts

Request body

The selection above, plus:

FieldTypeRequiredDescription
statusstringnoOne of pending, in_progress, completed, cancelled.
prioritystringnoOne of low, medium, high, urgent.
{
  "all": true,
  "filters": { "statuses": ["pending"], "priorities": ["high"] },
  "status": "completed"
}

Response

The number of tasks written, rather than the rows: a select-all can cover tens of thousands.

{ "affected": 128 }

Bulk delete tasks

DELETE /crm/tasks

Delete every task in a selection. The body is the selection object, or a bare array of ids.

Auth: Scope WRITE_CRM · Org permission manage_contacts

{ "all": true, "filters": { "query": "out_of_office" } }

Response

{ "affected": 128 }

CRM provider mode

A workspace can run its CRM on HubSpot or Pipedrive instead of Warmbly's own. In provider mode the endpoints above keep their shapes, and the records they return are the provider's, mirrored into Warmbly. In HubSpot mode:

  • a create, update or delete of a deal, task or note is written to HubSpot before it is saved. A refusal from HubSpot comes back as an error and nothing is saved; see CRM provider refusals
  • creating, updating or deleting a pipeline, stage or task type answers 409 crm_managed_externally. GET /crm/pipelines lists only HubSpot pipelines, and GET /crm/task-types returns HubSpot's four types (To-do, Call, Email, LinkedIn)
  • POST /crm/deals/search and POST /crm/deals/summary cover only HubSpot pipelines when no pipeline_ids filter is given, so deals in Warmbly-only pipelines are left out
  • setting a deal's status to won or lost moves it to the pipeline's first closed stage of that kind, and moving it to a closed stage sets its status. assigned_to must be a member matched to a HubSpot owner (400 crm_owner_unmapped)

In Pipedrive mode the same holds, with Pipedrive's model:

  • tasks are Pipedrive activities. GET /crm/task-types returns the company's activity types; a task's status is written as done (completed, cancelled) or not done, and its priority stays in Warmbly
  • a deal's status is Pipedrive's own deal status, so won and lost keep the deal's stage. lost_reason is written with a lost status. assigned_to must be a member matched to a Pipedrive user (400 crm_owner_unmapped)
  • Pipedrive has no lifecycle stage or lead status. A contact's lifecycle_stage is its first person label (an id, as in GET /crm/metadata), and lead_status is always empty

Deals, tasks, notes and pipelines that live in the provider carry an external object, and the stages of a mirrored pipeline carry its stage metadata. Both are absent outside provider mode.

FieldTypeDescription
external.providerstringhubspot or pipedrive.
external.external_idstringThe provider's record id.
external.urlstringWhere the record opens in the provider: the deal, the tasks view (in Pipedrive the activity's deal, else the activities list), the note's contact, or the pipeline. Omitted when unknown.
external.synced_atstring (date-time)When the record was last written to or read from the provider.
external.owner_namestringThe provider's owner, on a deal or task whose owner is not a workspace member (so assigned_to is empty).
stages[].closedbooleanThe stage is a closed stage in HubSpot. Omitted when false, and always in Pipedrive.
stages[].wonbooleanThe stage is closed won in HubSpot. Omitted when false, and always in Pipedrive.
stages[].probabilitynumberThe provider's deal probability for the stage, from 0 to 1.
{
  "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567",
  "name": "Acme renewal",
  "status": "open",
  "external": {
    "provider": "hubspot",
    "external_id": "18342007291",
    "url": "https://app.hubspot.com/contacts/4711/record/0-3/18342007291",
    "synced_at": "2026-06-10T09:30:04Z",
    "owner_name": "Dana Kim"
  }
}

The routes below configure provider mode and read the provider's side of a contact; each one acts on the CRM the workspace runs on. Except for GET /crm/settings, GET /crm/owners, GET /crm/sync and GET /crm/backfill, each answers 409 crm_not_connected while the workspace is not in provider mode, and 501 on an instance where CRM providers are not available.

Get CRM settings

GET /crm/settings

Return the workspace's CRM mode, its setup choices and the connected account. A workspace that never chose returns provider: "native" with the default choices.

Auth: Scope READ_CRM · Org permission view_contacts

Response

{
  "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "provider": "hubspot",
  "connection_id": "6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b",
  "config": {
    "activity": { "sent": true, "replies": true, "bounces": true, "unsubscribes": true, "opens": false, "clicks": false, "meetings": true },
    "create_contacts": true,
    "create_companies": true,
    "write_properties": true,
    "positive_reply": { "lead_status": "IN_PROGRESS", "lifecycle_stage": "", "create_deal": false },
    "exit_rules": { "deal_created": true, "lifecycle_stages": ["opportunity", "customer"], "opted_out": true },
    "guards": { "skip_lifecycle_stages": ["customer", "evangelist"], "skip_open_deals": true, "skip_other_owners": false, "skip_opted_out": true },
    "deal_pipelines": [],
    "display_properties": ["jobtitle"],
    "field_map": { "first_name": "firstname", "last_name": "lastname", "company": "company", "phone": "phone" },
    "field_direction": { "first_name": "both", "last_name": "both", "company": "both", "phone": "both" }
  },
  "setup_completed_at": "2026-06-01T12:04:00Z",
  "updated_at": "2026-06-01T12:04:00Z",
  "account": {
    "external_id": "4711",
    "name": "acme.com",
    "app_url": "https://app.hubspot.com/contacts/4711",
    "status": "connected",
    "health": "healthy"
  }
}

account.missing_scopes lists the permissions the connection lacks for provider mode, when there are any. config.for names the provider the choices were made for. In Pipedrive mode account.external_id is the Pipedrive company id, and config.positive_reply.create_lead adds the person to Pipedrive's Leads Inbox on an interested reply.

Update CRM settings

PUT /crm/settings

Choose the CRM and store the setup choices. Switching to HubSpot creates the Warmbly contact property group in HubSpot (when write_properties is on) and starts the first pull in the background. Switching to Pipedrive fills the label rules from the company's own labels when no config is sent, then adds the Warmbly person fields, registers the change notifications and starts the first pull. Switching from one provider to the other replaces it, and stored choices made for the other provider are reset to the new one's defaults. Naturally safe to retry: it writes an end state.

Auth: Scope INTEGRATIONS · Org permission manage_settings

Request body

FieldTypeRequiredDescription
providerstringnonative, hubspot or pipedrive.
connection_iduuidnoThe workspace's HubSpot or Pipedrive integration connection. Required once, to use the provider.
configobjectnoThe whole set of choices, replacing the stored one. Shape as in the response above.
complete_setupbooleannoMark the setup wizard as finished.

config is validated before it is stored: field_map keys are first_name, last_name, email, company, phone or custom:<key>; each field_direction entry is push, pull or both and names a mapped field; deal_pipelines (provider pipeline ids, empty for all) holds at most 50 entries, display_properties at most 40, field_map at most 100, and each lifecycle stage list at most 20. positive_reply.create_deal needs deal_pipeline_id and deal_stage_id, which are Warmbly's ids for a mirrored pipeline and stage.

{
  "provider": "hubspot",
  "connection_id": "6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b",
  "complete_setup": true
}

Response

The settings object, as returned by GET /crm/settings. A connection without the permissions provider mode needs answers 409 crm_reauth_required; a connection_id that is not one of the workspace's connections to that provider answers 400.

Get CRM metadata

GET /crm/metadata

Return the provider's vocabulary for the setup pickers: lifecycle stages, lead statuses, task types, contact properties (hidden and calculated ones left out) and deal pipelines. Pipelines here carry the provider's ids, as deal_pipelines expects. For Pipedrive, lifecycle_stages are the person labels, lead_statuses is empty, task_types are the activity types, and properties are the standard person keys (first_name, last_name, name, phone, org_name) followed by the custom fields by code.

Auth: Scope READ_CRM · Org permission view_contacts

Response

{
  "lifecycle_stages": [{ "value": "lead", "label": "Lead" }, { "value": "customer", "label": "Customer" }],
  "lead_statuses": [{ "value": "IN_PROGRESS", "label": "In Progress" }],
  "task_types": [{ "value": "TODO", "label": "To-do" }, { "value": "CALL", "label": "Call" }],
  "properties": [{ "name": "jobtitle", "label": "Job Title", "type": "string", "group_name": "contactinformation", "read_only": false }],
  "pipelines": [{ "value": "default", "label": "Sales Pipeline" }]
}

List CRM owners

GET /crm/owners

List the provider's owners (Pipedrive's users) and the member each one is matched to. Owners are matched by email address unless a match was chosen by hand (user_pinned).

Auth: Scope READ_CRM · Org permission view_contacts

Response

{
  "data": [
    {
      "external_id": "82731",
      "email": "[email protected]",
      "first_name": "Dana",
      "last_name": "Kim",
      "user_id": "bb22cc33-dd44-4ee5-86ff-770011223344",
      "user_pinned": false,
      "archived": false
    }
  ]
}

Match a CRM owner

PUT /crm/owners/:externalId

Match a provider owner to a member, or clear the match with null. A match set here is kept when email matching runs again. Naturally safe to retry.

Auth: Scope INTEGRATIONS · Org permission manage_settings

ParameterInTypeDescription
externalIdpathstringThe provider's owner (Pipedrive user) id.

Request body

FieldTypeRequiredDescription
user_iduuid or nullyesThe member, or null to clear.

Response

204 No Content. 404 when the owner or the member is not in the workspace.

Get sync health

GET /crm/sync

Report the sync queue (pending, failed, and finished in the last 24 hours), the latest 50 failures, each pull's last run, and how many records are mirrored.

Auth: Scope READ_CRM · Org permission view_contacts

Response

{
  "pending": 2,
  "failed": 1,
  "done_24h": 418,
  "last_synced_at": "2026-06-10T09:31:12Z",
  "cursors": [
    { "object_type": "deals", "cursor_at": "2026-06-10T09:30:58Z", "last_run_at": "2026-06-10T09:31:02Z" }
  ],
  "failures": [
    {
      "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
      "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "provider": "hubspot",
      "kind": "push_deal",
      "subject": "Acme renewal",
      "status": "failed",
      "attempts": 6,
      "next_attempt_at": "2026-06-10T03:12:00Z",
      "last_error": "HubSpot refused the change: Property \"amount\" is required",
      "created_at": "2026-06-09T18:02:00Z",
      "updated_at": "2026-06-10T03:12:04Z",
      "finished_at": "2026-06-10T03:12:04Z"
    }
  ],
  "counts": { "contacts": 1820, "deals": 64, "tasks": 211, "pipelines": 2, "owners": 9 }
}

cursors[].object_type is owners, pipelines, deals, tasks or contacts, with last_error set when its last run failed.

Sync now

POST /crm/sync

Start a full pull from the provider in the background.

Auth: Scope INTEGRATIONS · Org permission manage_settings

Response

202 Accepted. A second call within 30 seconds answers 429 crm_sync_running.

Retry or discard failed syncs

POST /crm/sync/retry · POST /crm/sync/discard

Requeue failed sync items, or drop them. Without a body (or with an empty ids) every failed item is affected. Naturally safe to retry: an item already requeued or dropped is not failed any more.

Auth: Scope INTEGRATIONS · Org permission manage_settings

Request body

FieldTypeRequiredDescription
idsarray of uuidnoFailed items to act on, at most 500.

Response

{ "affected": 3 }

Copy Warmbly CRM data into the provider

GET /crm/backfill · POST /crm/backfill

GET counts the deals, tasks and notes that live only in Warmbly, in no connected CRM (records mirrored from a CRM the workspace used before are left out). POST copies the kinds you pick, once, in the background: deals move onto the first mirrored pipeline, keeping a stage of the same name or else taking the first stage that matches their status (in Pipedrive, the first stage, with the deal's status kept). A copied record is never copied twice, and a second POST while one runs is folded into it.

Auth: Scope INTEGRATIONS · Org permission manage_settings

Request body

FieldTypeRequiredDescription
dealsbooleannoCopy deals.
tasksbooleannoCopy tasks.
notesbooleannoCopy notes.

At least one must be true.

Response

GET returns { "deals": 12, "tasks": 40, "notes": 7 }. POST returns 202 Accepted; progress shows in sync health.

Get a contact's CRM record

GET /crm/contacts/:id

Return the provider's side of a Warmbly contact as last mirrored: owner, lifecycle stage (a label in Pipedrive), lead status, company (organization in Pipedrive), opt-out and the properties chosen in display_properties. A contact not linked to the provider returns linked: false and nothing else.

Auth: Scope READ_CRM · Org permission view_contacts

ParameterInTypeDescription
idpathuuidWarmbly contact ID.

Response

{
  "provider": "hubspot",
  "linked": true,
  "external_id": "51234",
  "url": "https://app.hubspot.com/contacts/4711/record/0-1/51234",
  "owner": { "external_id": "82731", "email": "[email protected]", "first_name": "Dana", "last_name": "Kim", "user_pinned": false, "archived": false },
  "lifecycle_stage": { "value": "lead", "label": "Lead" },
  "lead_status": { "value": "IN_PROGRESS", "label": "In Progress" },
  "company": { "external_id": "9921", "name": "Acme", "domain": "acme.com", "url": "https://app.hubspot.com/contacts/4711/record/0-2/9921" },
  "opted_out": false,
  "properties": [{ "name": "jobtitle", "label": "Job Title", "value": "Head of Sales" }],
  "synced_at": "2026-06-10T09:30:04Z"
}

Refresh a contact's CRM record

POST /crm/contacts/:id/refresh

Pull the contact's provider record, deals, tasks and notes now. An unlinked contact is looked up by email and linked when the provider has it; nothing is created. Debounced per contact, so it is safe to call whenever a contact is opened: in HubSpot one pull a minute and one lookup every five minutes; in Pipedrive, which meters API use per company per day, the person is re-read at most every two minutes, their deals, activities and notes every six hours, and an unlinked contact is looked up every fifteen minutes.

Auth: Scope READ_CRM · Org permission view_contacts

Response

The contact's CRM record, as in GET /crm/contacts/:id.

POST /crm/contacts/:id/link

Find the contact in the provider by email, or create it there when the workspace allows creating contacts, then pull its deals, tasks and notes. Naturally safe to retry: a linked contact stays linked to the same record.

Auth: Scope WRITE_CRM · Org permission manage_contacts

Response

The contact's CRM record, as in GET /crm/contacts/:id. 404 crm_contact_missing when the provider does not have the contact and creating contacts is off.

Update a contact's CRM record

PATCH /crm/contacts/:id

Write the contact's owner, lifecycle stage or lead status to the provider, then to the mirror. In Pipedrive, lifecycle_stage sets the person's label (an id from GET /crm/metadata, or empty to clear it) and lead_status is ignored.

Auth: Scope WRITE_CRM · Org permission manage_contacts

Request body

FieldTypeRequiredDescription
owner_external_idstringnoThe provider's owner (Pipedrive user) id.
lifecycle_stagestringnoA lifecycle stage value from GET /crm/metadata.
lead_statusstringnoA lead status value from GET /crm/metadata.

Each value is at most 100 characters.

Response

The updated provider record, as in GET /crm/contacts/:id. 404 crm_contact_missing when the contact is not linked to the provider.

List CRM lists

GET /crm/lists

List the HubSpot account's contact lists, newest first, for import. Needs HubSpot's optional list permission; a connection without it answers 409 crm_reauth_required. In Pipedrive mode these are the company's saved people filters, most recently changed first, each with size: -1 (Pipedrive does not count a filter) and dynamic: true.

Auth: Scope READ_CRM · Org permission manage_contacts

ParameterInTypeDescription
qquerystringFilter by list name (at most 200 characters).
cursorquerystringOpaque cursor from a previous page's pagination.next_cursor. An unrecognized one answers 400.
limitqueryintPage size, 1 to 100 (default 25).

Response

{
  "data": [
    { "external_id": "212", "name": "Q3 webinar attendees", "size": 840, "dynamic": true, "updated_at": "2026-06-08T15:20:00Z" }
  ],
  "pagination": { "next_cursor": "bGlzdDoyNQ", "has_more": true }
}

Preview a CRM list import

POST /crm/lists/preview

Count who a list would bring in and who it skips, by reason, without writing anything.

Auth: Scope WRITE_CONTACTS · Org permission manage_contacts

Request body

FieldTypeRequiredDescription
list_idstringyesHubSpot list id or Pipedrive filter id (at most 64 characters).
apply_guardsbooleannoSkip the contacts the workspace's import guards exclude (config.guards).

Response

{
  "list_name": "Q3 webinar attendees",
  "total": 840,
  "included": 772,
  "skipped": [
    { "reason": "opted_out", "label": "Opted out of email in HubSpot", "count": 31 },
    { "reason": "lifecycle", "label": "Lifecycle stage is Customer", "count": 25 },
    { "reason": "no_email", "label": "No email address", "count": 12 }
  ],
  "sample": [{ "email": "[email protected]", "first_name": "Sam", "last_name": "Lee", "company": "Globex" }],
  "truncated": false
}

reason is opted_out, lifecycle, open_deal, other_owner, no_email or duplicate. One import reads at most 25,000 contacts; truncated is true when the list is larger.

Import a CRM list

POST /crm/lists/import

Turn a list into a contact import draft, finished in the regular import review. The body and the skip rules are those of the preview. The imported contacts are linked to their provider records a few minutes later. The draft belongs to the caller (for an API key, the member who created it).

Auth: Scope WRITE_CONTACTS · Org permission manage_contacts

Response

201 Created.

{
  "import_id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
  "preview": { "list_name": "Q3 webinar attendees", "total": 840, "included": 772, "skipped": [], "sample": [], "truncated": false }
}

400 when nobody in the list can be imported.

Errors

All endpoints return the standard error envelope on failure, for example a malformed UUID path param or an invalid request body. See error codes for the full list.

{
  "error": "invalid_request",
  "message": "invalid request body",
  "code": "INVALID_REQUEST",
  "request_id": "req_8f2c1a90b34d4e6f"
}

On this page