# Salesbase CRM API — Integration Reference

> Version: 1.0 — Base URL: `/api/v1`

---

## Authentication

Integration endpoints use a workspace API key (Bearer sb_…). API keys are created in the Salesbase app under Settings → Workspace → Automation → API Keys. Mobile app endpoints (voice, notifications, timers, assistant, devices, workspaces switch) require JWT login and are not part of this integration reference.

### API Key

- **Format:** `Authorization: Bearer <YOUR_API_KEY>`
- **Alternative:** Query parameter: ?api_key=<YOUR_API_KEY> (for tools that cannot set headers)
- **Prefix:** API keys start with 'sb_' prefix
- **Note:** The full API key is only shown once when created. It is stored as a SHA-256 hash — Salesbase cannot recover lost keys.

### Rate Limits

- 120 requests per minute
- 50,000 requests per day

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit-Minute` | Maximum requests allowed per minute (120) |
| `X-RateLimit-Remaining-Minute` | Remaining requests in the current minute window |
| `X-RateLimit-Limit-Day` | Maximum requests allowed per day (50,000) |
| `X-RateLimit-Remaining-Day` | Remaining requests in the current day window |

### Error Codes

| Status | Description |
|--------|-------------|
| 400 | Bad Request — invalid parameters or missing required fields |
| 401 | Unauthorized — missing or invalid API key |
| 403 | Forbidden — API key is revoked or workspace is suspended |
| 404 | Not Found — resource does not exist or belongs to another workspace |
| 429 | Too Many Requests — rate limit exceeded |
| 500 | Internal Server Error — unexpected server error |

### Response Format

- **Success (single):** `{ "data": { "_id": "...", "name": "...", ... } }`
- **Success (list):** `{ "data": [...], "page": 1, "limit": 20, "total": 150 }`
- **Error:** `{ "error": "Description of what went wrong" }`

### Quick Start

1. **Create an API key** — Go to Settings → Workspace → Automation → API Keys and create a new key. Copy the key immediately — it won't be shown again.
2. **Test the connection** — Make a GET request to /api/v1/whoami to verify your key and see workspace name, user, and scopes

```bash
curl -H "Authorization: Bearer sb_your_api_key_here" https://app.salesbase.dk/api/v1/leads
```

3. **Explore the API** — Use GET /api/v1/docs to get the full API documentation as JSON
### Best Practices

- Store API keys in environment variables, never in code
- Use the minimum required permissions/scopes for each key
- Rotate keys periodically and revoke unused keys
- Handle rate limit errors (429) with exponential backoff
- Always include error handling for network failures
- Use the workspace-scoped endpoints — all data is automatically filtered by your workspace

---

## API Endpoints

Base URL: `/api/v1` — All endpoints require Authorization: Bearer <API_KEY> header (or ?api_key= query param)

Pagination: Most list endpoints accept page (default 1) and limit (default 20, max 100) query params

### Inbound (unified creation)

Create companies, people, leads, and deals in one request. Ideal for form submissions and integrations.

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/inbound` | Create or find-and-update company/person/lead/deal from a single payload. Supports JSON, form-urlencoded, and Elementor webhook formats. |
| `GET` | `/api/v1/inbound` | Returns API documentation for the inbound endpoint |

#### `POST /api/v1/inbound`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `companyName` | string | No | Company name (for company lookup/creation) |
| `companyCvr` | string | No | Danish CVR number (for exact company match) |
| `companyEmail` | string | No | — |
| `companyPhone` | string | No | — |
| `personFirstName` | string | No | — |
| `personLastName` | string | No | — |
| `personEmail` | string | No | — |
| `personPhone` | string | No | — |
| `personTitle` | string | No | — |
| `leadTitle` | string | No | Lead title (auto-generated if not provided) |
| `leadSource` | string | No | — |
| `leadStatus` | string | No | — |
| `leadNotes` | string | No | — |
| `dealTitle` | string | No | — |
| `dealValue` | number | No | — |
| `dealFlowId` | string | No | Pipeline ID |
| `dealStageId` | string | No | Stage ID within pipeline |
| `customFields` | object | No | Key-value pairs for custom fields |
| `tags` | string[] | No | Array of tag names to apply |

### Session

Identity for the authenticated API key or MCP session.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/whoami` | Return workspace name, user name, API key prefix, and effective scopes for the current session. Any valid key or MCP token works (no extra scope required). MCP clients should call this first. |
| `GET` | `/api/v1/docs` | API documentation index or a section. Query: section, subsection, type, index=true, full=true. No authentication required. |
| `GET` | `/api/v1/docs/markdown` | Full API documentation as Markdown. |

### Search

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/search` | Search across CRM entities, projects, offers, and tasks |

#### `GET /api/v1/search`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `q` | string | Yes | — | Search query (minimum 2 characters) |
| `types` | string | No | — | Comma-separated entity types: company,person,lead,deal,support-ticket,project,offer,task (default: all) |
| `limit` | number | No | 10 | Max results per entity type (max 50) |

### CVR

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/cvr/match` | Match a Danish CVR company by LinkedIn URL, domain, or exact name (Chrome extension). |

#### `GET /api/v1/cvr/match`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `linkedin` | string | No | — | — |
| `domain` | string | No | — | — |
| `name` | string | No | — | — |

### Chrome extension

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/extension/contribution` | Get workspace Bidrag til Salesbase-data status. |
| `PUT` | `/api/v1/extension/contribution` | Admin: enable or disable contribution. |
| `POST` | `/api/v1/extension/observations` | Ingest observation batches (contribute scope). |
| `POST` | `/api/v1/extension/captures/presign` | Presign S3 uploads for a page capture (contribute scope). |
| `GET` | `/api/v1/extension/network` | Give-to-get network contacts and signals (contributing workspaces only). |
| `GET` | `/api/v1/extension/domains` | Known company domains for the extension local filter. |

#### `PUT /api/v1/extension/contribution`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `enabled` | boolean | Yes | — |

#### `POST /api/v1/extension/observations`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `observations` | array | Yes | — |

#### `GET /api/v1/extension/network`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `linkedin` | string | No | — | — |
| `handle` | string | No | — | — |

### Companies

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/companies` | List companies. view=full returns stored fields including integration ids (ordrestyringCustomerNumber, economicCustomerNumber, …). fields= projects any schema field at Mongo. limit max 500. MCP defaults to view=summary. |
| `GET` | `/api/v1/companies/lookup` | Look up a company by LinkedIn URL or website domain (Chrome extension). |
| `POST` | `/api/v1/companies` | Create a company |
| `GET` | `/api/v1/companies/:id` | Get company by ID. Optional fields= query projects stored fields (including integration ids). |
| `PUT` | `/api/v1/companies/:id` | Update company. May set website, avatar (logo) and logoUrl. avatar/logoUrl accept an https image URL (re-hosted as a workspace image) or a data:image/...;base64 URL (max 2 MB). customFields is a partial key→value merge (definitions: GET /api/v1/custom-fields?entityType=company). |
| `DELETE` | `/api/v1/companies/:id` | Delete company |
| `POST` | `/api/v1/companies/:id/find-web-presence` | Find and fill empty website, logo/avatar and LinkedIn from CVR, the company e-mail domain and a web search (page verified by name or CVR). Same as CRM action Find hjemmeside og logo. Does not overwrite filled fields. Response data.status: updated | already_filled | not_found; data.hint says what to do next when nothing was found. |
| `GET` | `/api/v1/companies/:id/people` | List people connected to company |
| `GET` | `/api/v1/companies/:id/deals` | List deals connected to company |
| `GET` | `/api/v1/companies/:id/activities` | List activities for company |
| `GET` | `/api/v1/companies/:id/activity-feed` | Unified activity feed for company |
| `POST` | `/api/v1/companies/:id/people` | Link person to company |
| `DELETE` | `/api/v1/companies/:id/people` | Unlink person from company |

#### `GET /api/v1/companies`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `q` | string | No | — | — |
| `view` | string | No | full |  (summary, full) |
| `fields` | string | No | — | Comma-separated stored field names |
| `updatedSince` | string | No | — | — |
| `dateFrom` | string | No | — | — |
| `dateTo` | string | No | — | — |

#### `GET /api/v1/companies/lookup`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `linkedin` | string | No | — | — |
| `domain` | string | No | — | — |

#### `POST /api/v1/companies`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `cvr` | string | No | — |
| `email` | string | No | — |
| `phone` | string | No | — |
| `address` | string | No | — |
| `city` | string | No | — |
| `zip` | string | No | — |
| `country` | string | No | — |
| `website` | string | No | — |
| `industry` | string | No | — |
| `employees` | number | No | — |
| `customFields` | array | No | — |
| `tags` | string[] | No | — |

#### `GET /api/v1/companies/:id`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `fields` | string | No | — | — |

#### `PUT /api/v1/companies/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `website` | string | No | — |
| `avatar` | string | No | Image URL (re-hosted as a workspace image), data:image/...;base64 URL (max 2 MB), or empty to clear |
| `logoUrl` | string | No | Image URL (re-hosted as a workspace image), data:image/...;base64 URL (max 2 MB), or empty to clear |
| `customFields` | object | No | Partial key → typed value merge |

### People (contacts)

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/people` | List people. view=full returns stored fields including integration ids. fields= projects any schema field at Mongo. limit max 500. MCP defaults to view=summary. |
| `GET` | `/api/v1/people/lookup` | Look up a person by LinkedIn URL (Chrome extension). |
| `POST` | `/api/v1/people` | Create a person |
| `GET` | `/api/v1/people/:id` | Get person by ID. Optional fields= query. |
| `PUT` | `/api/v1/people/:id` | Update person. May set avatar: an https image URL (re-hosted as a workspace image) or a data:image/...;base64 URL (max 2 MB). customFields is a partial key→value merge (definitions: GET /api/v1/custom-fields?entityType=person). |
| `DELETE` | `/api/v1/people/:id` | Delete person |
| `POST` | `/api/v1/people/:id/find-avatar` | Find and fill an empty avatar from public sources (Gravatar, LinkedIn, company site). Same as CRM action Find billede. Does not overwrite an existing avatar. Response data.status: updated | already_filled | not_found, plus data.hint. |
| `GET` | `/api/v1/people/:id/companies` | List companies connected to person |
| `GET` | `/api/v1/people/:id/deals` | List deals connected to person |
| `GET` | `/api/v1/people/:id/activities` | List activities for person |
| `GET` | `/api/v1/people/:id/activity-feed` | Unified activity feed for person |

#### `GET /api/v1/people/lookup`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `linkedin` | string | Yes | — | — |

#### `POST /api/v1/people`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `firstName` | string | Yes | — |
| `lastName` | string | No | — |
| `emails` | array | No | [{ email, label }] |
| `phones` | array | No | [{ phone, label }] |
| `title` | string | No | — |
| `companyId` | string | No | — |
| `customFields` | array | No | — |
| `tags` | string[] | No | — |

#### `GET /api/v1/people/:id`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `fields` | string | No | — | — |

#### `PUT /api/v1/people/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `avatar` | string | No | Image URL (re-hosted as a workspace image), data:image/...;base64 URL (max 2 MB), or empty to clear |
| `customFields` | object | No | Partial key → typed value merge |

### Leads

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/leads` | List leads. view=full returns stored documents. fields= projects any schema field. limit max 500. MCP defaults to view=summary. |
| `POST` | `/api/v1/leads` | Create a lead. Score and scoreBreakdown are calculated automatically from active lead-scoring rules after create (do not rely on a client-supplied score). Score 0 with empty breakdown means no active rules matched; configure rules via GET/POST /api/v1/lead-scoring/rules. |
| `GET` | `/api/v1/leads/:id` | Get lead by ID (includes score and scoreBreakdown from the last automatic calculation) |
| `PUT` | `/api/v1/leads/:id` | Update lead. customFields is a partial key→value merge (definitions: GET /api/v1/custom-fields?entityType=lead). Score is recalculated automatically after update from active lead-scoring rules; score 0 means no rules matched. |
| `DELETE` | `/api/v1/leads/:id` | Delete lead |
| `GET` | `/api/v1/leads/:id/activity-feed` | Unified activity feed for lead |
| `GET` | `/api/v1/leads/:id/activities` | List activities for lead |

#### `GET /api/v1/leads`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `status` | string | No | — | — |
| `source` | string | No | — | — |
| `search` | string | No | — | — |
| `view` | string | No | — |  (summary, full) |
| `fields` | string | No | — | — |

#### `POST /api/v1/leads`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | Yes | — |
| `email` | string | No | — |
| `phone` | string | No | — |
| `status` | string | No | — |
| `source` | string | No | — |
| `score` | number | No | Ignored for scoring: server recalculates from rules after create |
| `value` | number | No | — |
| `notes` | string | No | — |
| `companyRef` | string | No | Company ID |
| `personRef` | string | No | Person ID |
| `customFields` | object | No | — |
| `tags` | string[] | No | — |

#### `PUT /api/v1/leads/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `customFields` | object | No | Partial key → typed value merge |

### Deals

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/deals` | List deals. Unknown query params return 400. Use view=summary for slim payloads; updatedSince for incremental sync. |
| `POST` | `/api/v1/deals` | Create a deal |
| `GET` | `/api/v1/deals/:id` | Get deal by ID |
| `PUT` | `/api/v1/deals/:id` | Update deal (logs field/stage changes to activity feed, same as UI). customFields is a partial key→value merge (definitions: GET /api/v1/custom-fields?entityType=deal). |
| `DELETE` | `/api/v1/deals/:id` | Delete deal |
| `POST` | `/api/v1/deals/:id/move` | Move deal to a different stage |
| `GET` | `/api/v1/deals/:id/activities` | List activities for deal |
| `GET` | `/api/v1/deals/:id/activity-feed` | Unified activity feed for deal |

#### `GET /api/v1/deals`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `q` | string | No | — | — |
| `status` | string | No | — |  (open, won, lost) |
| `flowId` | string | No | — | Pipeline id (alias: flowRef) |
| `stageId` | string | No | — | Stage id/number (alias: stage) |
| `ownerId` | string | No | — | Owner user id (alias: owner) |
| `sortBy` | string | No | — | title|value|updatedAt|createdAt|status|lostDate|wonDate|stage |
| `sortOrder` | string | No | — |  (asc, desc) |
| `view` | string | No | full |  (summary, full) |
| `fields` | string | No | — | — |
| `updatedSince` | string | No | — | ISO — updatedAt >= |
| `createdAfter` | string | No | — | — |
| `dateFrom` | string | No | — | createdAt >= (aliases: startDate, from) |
| `dateTo` | string | No | — | createdAt <= (aliases: endDate, to) |

#### `POST /api/v1/deals`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | Yes | — |
| `value` | number | No | — |
| `flowId` | string | No | — |
| `stageId` | string | No | — |
| `priority` | string | No | — |
| `probability` | number | No | — |
| `company` | string | No | Company ID |
| `personRef` | string | No | Person ID |
| `leadRef` | string | No | Lead ID |
| `customFields` | object | No | — |
| `tags` | string[] | No | — |

#### `PUT /api/v1/deals/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `customFields` | object | No | Partial key → typed value merge |

#### `POST /api/v1/deals/:id/move`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stageId` | string | Yes | — |
| `flowId` | string | No | — |

### Activities

Aktiviteter / activity feed — list and create feed rows (noter, opkald, deals m.m.)

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/activities` | List activities (aktiviteter). Unknown query params return 400. |
| `POST` | `/api/v1/activities` | Create activity / aktivitet (creates a feed row). Body: type (required), summary or title, description, companyId/personId/dealId/leadId, entityType+entityId, noteRef, date, metadata |
| `GET` | `/api/v1/activities/:id` | Get activity by ID (aktivitet) |

#### `GET /api/v1/activities`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `type` | string | No | — | — |
| `companyId` | string | No | — | — |
| `personId` | string | No | — | — |
| `dealId` | string | No | — | — |
| `leadId` | string | No | — | — |
| `dateFrom` | string | No | — | Activity date >= (aliases: startDate, from) |
| `dateTo` | string | No | — | Activity date <= (aliases: endDate, to) |
| `updatedSince` | string | No | — | — |
| `createdAfter` | string | No | — | — |
| `view` | string | No | — |  (summary, full) |
| `q` | string | No | — | — |
| `ownerId` | string | No | — | — |

### Tasks

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/tasks` | List tasks. Coarse status is to_do, in_progress, blocked, or done (not open or cancelled). The task list Status column and done checkbox follow stage from GET /api/v1/task-flow. |
| `POST` | `/api/v1/tasks` | Create a task (Mine opgaver or project task). Requires title plus exactly one parent: project, customObjectRecord (+ customObjectSlug), company, personRef, leadRef, or dealRef. Field name is company (not companyRef). |
| `GET` | `/api/v1/tasks/:id` | Get task |
| `PUT` | `/api/v1/tasks/:id` | Update task. status done also sets stage to completedStageId from GET /api/v1/task-flow. stage sets status. The list Status column and done checkbox read stage. |
| `DELETE` | `/api/v1/tasks/:id` | Delete task |

#### `GET /api/v1/tasks`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `status` | string | No | — |  (to_do, in_progress, blocked, done) |
| `priority` | string | No | — | — |
| `ownerId` | string | No | — | Assignee (aliases: owner, assignee) |
| `project` | string | No | — | — |
| `q` | string | No | — | — |
| `view` | string | No | — | — |
| `updatedSince` | string | No | — | — |
| `dateFrom` | string | No | — | — |
| `dateTo` | string | No | — | — |

#### `POST /api/v1/tasks`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | Yes | — |
| `project` | string | No | Project ID (omit when linking only to company/person/lead/deal) |
| `customObjectRecord` | string | No | Custom object record ID; requires customObjectSlug |
| `customObjectSlug` | string | No | — |
| `company` | string | No | Company ID (standalone task on company; not companyRef) |
| `personRef` | string | No | Person ID (standalone task on person) |
| `leadRef` | string | No | Lead ID |
| `dealRef` | string | No | Deal ID |
| `description` | string | No | — |
| `status` | string | No | — |
| `priority` | string | No | — |
| `assignees` | string[] | No | User IDs; defaults to API caller |
| `deadline` | string | No | ISO date |
| `tags` | string[] | No | — |
| `customFields` | object | No | — |
| `estimatedHours` | number | No | — |
| `taskValue` | number | No | — |
| `owner` | string | No | User ID; defaults to API caller |

#### `PUT /api/v1/tasks/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | — |
| `description` | string | No | — |
| `status` | string | No | Coarse status. done moves stage to the flow completedStageId so the list and checkbox show Færdig. |
| `stage` | number | No | Task-flow stage id from GET /api/v1/task-flow. Wins over status when both are sent. completedStageId marks the task done. |
| `priority` | string | No | — |
| `assignees` | string[] | No | — |
| `deadline` | string | No | ISO date |
| `tags` | string[] | No | — |
| `estimatedHours` | number | No | — |
| `taskValue` | number | No | — |
| `owner` | string | No | User ID |
| `customFields` | object | No | Partial key → typed value merge. Definitions: GET /api/v1/project-custom-fields?entityType=task |

### Notes

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/notes` | List notes. Supports q search; unknown params → 400. |
| `POST` | `/api/v1/notes` | Create note (also creates linked activity for Noter tab / activity feed). Requires entity ref: company, personRef, leadRef, dealRef, or customObjectRecord |
| `GET` | `/api/v1/notes/:id` | Get note |
| `PUT` | `/api/v1/notes/:id` | Update note (syncs linked activity) |
| `DELETE` | `/api/v1/notes/:id` | Delete note (removes linked activity) |

#### `GET /api/v1/notes`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `q` | string | No | — | — |
| `personRef` | string | No | — | — |
| `company` | string | No | — | — |
| `leadRef` | string | No | — | — |
| `dealRef` | string | No | — | — |
| `dateFrom` | string | No | — | — |
| `dateTo` | string | No | — | — |
| `updatedSince` | string | No | — | — |
| `createdAfter` | string | No | — | — |
| `view` | string | No | — |  (summary, full) |

### Calls

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/calls` | List calls |
| `POST` | `/api/v1/calls` | Create call record (also creates call activity in feed) |
| `GET` | `/api/v1/calls/:id` | Get call by ID including transcriptText, transcriptSummary, and slim conversation turns (speaker/text/timing). Recording download URLs are JWT/session-only. |
| `PUT` | `/api/v1/calls/:id` | Update call (syncs linked activity) |
| `DELETE` | `/api/v1/calls/:id` | Delete call (removes linked activity) |

### Events (calendar)

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/events` | List events |
| `POST` | `/api/v1/events` | Create event |
| `GET` | `/api/v1/events/:id` | Get event |
| `PUT` | `/api/v1/events/:id` | Update event |
| `DELETE` | `/api/v1/events/:id` | Delete event |

### Tags

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/tags` | List unique tags |
| `POST` | `/api/v1/tags/bulk` | Bulk add/remove tags on multiple entities |

#### `GET /api/v1/tags`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `entityType` | string | No | — |  (company, person, deal, lead) |

### Custom Fields

Three field systems: (1) CRM defs via /api/v1/custom-fields for lead/deal/company/person (and support ticket values via entity PUT). (2) Project/task defs via GET /api/v1/project-custom-fields. (3) Custom object fields via GET /api/v1/custom-objects/:slug. Entity values: PUT customFields:{key:value} on the entity (partial merge). Custom object values: PATCH record with field-slug keys (or customFields alias).

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/custom-fields` | List CRM custom field definitions (lead, deal, company, person) |
| `POST` | `/api/v1/custom-fields` | Create custom field definition |
| `GET` | `/api/v1/custom-fields/:id` | Get custom field |
| `PUT` | `/api/v1/custom-fields/:id` | Update custom field |
| `DELETE` | `/api/v1/custom-fields/:id` | Delete custom field |
| `POST` | `/api/v1/custom-fields/search` | Search entities by custom field values |
| `GET` | `/api/v1/custom-fields/search` | Returns search endpoint documentation |
| `GET` | `/api/v1/project-custom-fields` | List project/task custom field definitions (CustomFieldDefinition). Use keys when setting customFields on project/task PUT. |

#### `GET /api/v1/custom-fields`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `entityType` | string | No | — |  (lead, deal, company, person) |

#### `GET /api/v1/project-custom-fields`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `entityType` | string | No | — | Optional filter (project, task) |
| `forCreateDialog` | string | No | — |  (true, false) |

### Flows & Stages (Pipelines)

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/flows` | List pipelines |
| `POST` | `/api/v1/flows` | Create pipeline |
| `GET` | `/api/v1/flows/:id` | Get pipeline |
| `PUT` | `/api/v1/flows/:id` | Update pipeline |
| `DELETE` | `/api/v1/flows/:id` | Delete pipeline |
| `POST` | `/api/v1/flows/:id/stages` | Create stage in pipeline |
| `PUT` | `/api/v1/flows/:id/stages` | Update stage |
| `DELETE` | `/api/v1/flows/:id/stages` | Delete stage |

#### `PUT /api/v1/flows/:id/stages`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | — |
| `title` | string | No | — |
| `order` | number | No | — |

#### `DELETE /api/v1/flows/:id/stages`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | — |

### AI agents

Custom AI agents. They do not own a cron. Start them from Automations with the Kør agent action. Schedule on create/update is always Via automation.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/agents` | List AI agents in the workspace |
| `POST` | `/api/v1/agents` | Create an AI agent. Enable it, then start it from an automation. |
| `GET` | `/api/v1/agents/:id` | Get one AI agent |
| `PUT` | `/api/v1/agents/:id` | Replace agent fields |
| `PATCH` | `/api/v1/agents/:id` | Update agent fields |
| `DELETE` | `/api/v1/agents/:id` | Delete an AI agent and its runs |

#### `POST /api/v1/agents`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `description` | string | No | — |
| `goal` | string | No | — |
| `systemPrompt` | string | No | — |
| `icon` | string | No | — |
| `modelTier` | string | No | fast, balanced or thorough |
| `enabled` | boolean | No | — |
| `permissions` | object | No | trustLevel, allowedModules, webAccess, maxActionsPerRun, maxDailyActions |

### Lists & Items

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/lists` | List all lists |
| `POST` | `/api/v1/lists` | Create list |
| `GET` | `/api/v1/lists/:id` | Get list |
| `PUT` | `/api/v1/lists/:id` | Update list |
| `DELETE` | `/api/v1/lists/:id` | Delete list |
| `GET` | `/api/v1/lists/:id/items` | List items in a list |
| `POST` | `/api/v1/lists/:id/items` | Add items to list |
| `DELETE` | `/api/v1/lists/:id/items` | Remove items from list |

### Emails

List and send mailbox emails. Single send uses html and/or text (not bodyHtml). Bulk send is POST /api/v1/emails/bulk (queues by default). There is no signatureId on send: fetch a signature via GET /api/v1/email-signatures and inline its HTML into html.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/emails` | List emails with inbox account access enforced. Unknown query params return 400. Use view=summary for slim payloads. |
| `POST` | `/api/v1/emails` | Send one email from a connected mailbox. Required: accountId, to, subject; html and/or text for content. bodyHtml is rejected on single send. No signatureId: inline signature HTML in html. |
| `POST` | `/api/v1/emails/bulk` | Bulk email. Requires write scope, email.create, and admin/owner. Queues by default (process-email-queue cron). Set immediate=true for sync send (max 10). Max 500 recipients. Prefer html/text; bodyHtml is accepted as an html alias here. |
| `GET` | `/api/v1/emails/:id` | Get email by ID |
| `GET` | `/api/v1/emails/:id/calendar-invite` | Get calendar invite for email |
| `POST` | `/api/v1/emails/:id/calendar-invite` | RSVP to calendar invite |
| `GET` | `/api/v1/emails/threads/:threadId` | Get email thread |

#### `POST /api/v1/emails`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `accountId` | string | Yes | Connected email account ID (GET /api/v1/email-accounts) |
| `to` | string|string[] | Yes | Recipient address or list of addresses |
| `subject` | string | Yes | — |
| `html` | string | No | HTML body. Preferred for rich mail. Not bodyHtml (templates only on single send). |
| `text` | string | No | Plain-text body (optional; use with or instead of html) |
| `cc` | string|string[] | No | CC recipients |
| `bcc` | string|string[] | No | BCC recipients |
| `linkPersonId` | string | No | Link sent message to person |
| `linkCompanyId` | string | No | Link sent message to company |
| `linkDealId` | string | No | Link sent message to deal |
| `trackOpens` | boolean | No | Enable open tracking when supported |
| `replyToMessageId` | string | No | Existing message ID when this send is a reply |

#### `POST /api/v1/emails/bulk`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `accountId` | string | Yes | Connected email account ID |
| `subject` | string | Yes | — |
| `html` | string | No | Shared HTML body (alias: bodyHtml) |
| `text` | string | No | Shared plain-text body |
| `recipients` | array | Yes | Email strings and/or { email, name?, personId?, companyId?, personalizedSubject?, personalizedHtml? } (to is accepted as email alias) |
| `trackOpens` | boolean | No | — |
| `immediate` | boolean | No | If true and recipients <= 10, send synchronously; otherwise queue |

### Email Accounts

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/email-accounts` | List connected email accounts |

### Email Templates

Create and manage email templates. Send bodyHtml (+ optional bodyCss and layoutSettings); Salesbase renders the full document. compiledHtml is accepted as a legacy full-document input. Supports {{contact.*}} placeholders and default filter syntax {{field | default:"fallback"}}.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/email-templates` | List email templates |
| `POST` | `/api/v1/email-templates` | Create email template (admin scope) |
| `GET` | `/api/v1/email-templates/:id` | Get template by ID (includes bodyHtml, bodyCss, compiledHtml, layoutSettings, projectData) |
| `PATCH` | `/api/v1/email-templates/:id` | Update template (admin scope) |
| `DELETE` | `/api/v1/email-templates/:id` | Delete template (admin scope) |

#### `POST /api/v1/email-templates`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `category` | string | No | Invalid values return 400 (not 500). Default when omitted: other. |
| `bodyHtml` | string | No | Body content with {{placeholders}} (preferred; no document wrapper) |
| `bodyCss` | string | No | Optional CSS rules for bodyHtml |
| `compiledHtml` | string | No | Legacy: full HTML document with {{placeholders}} |
| `plainText` | string | No | — |
| `metadata` | object | No | { subject, preheader } |
| `layoutSettings` | object | No | width (full|fixed), align (left|center|right, fixed only), containerWidth, textAlign, padding*, backgroundColor, outerBackground, fullWidthBackground. Deprecated aliases accepted: layoutMode (minimal|styled), useTableWrapper, containerAlign, outerBackgroundColor |
| `tags` | string[] | No | — |

### SMS

Requires API key scope `sms` (not covered by `write` alone). Message text field is body (aliases message and text). Sender field is fromNumber (alias from). Bulk send queues by default.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/sms` | List SMS messages. Unknown query params return 400. Use view=summary for slim payloads. Requires scope: sms. |
| `POST` | `/api/v1/sms` | Send one SMS. Requires scope: sms. Canonical body fields: to, body; optional fromNumber, linkPersonId, linkCompanyId, linkDealId. |
| `GET` | `/api/v1/sms/:id` | Get SMS by ID (Mongo ObjectId). Requires scope: sms. |
| `PUT` | `/api/v1/sms/:id` | Update SMS flags (unread, starred). Requires scope: sms. |
| `GET` | `/api/v1/sms/conversations` | List SMS conversation threads (one row per counterparty phone). Alias of /sms/threads. Requires scope: sms. |
| `GET` | `/api/v1/sms/threads` | List SMS conversation threads (same as /sms/conversations). Requires scope: sms. |
| `POST` | `/api/v1/sms/bulk` | Bulk SMS. Requires scope: sms and admin/owner. Queues by default (process-sms-queue cron). Set immediate=true for sync send (max 50). Max 100 recipients. |
| `POST` | `/api/v1/sms/schedule` | Schedule SMS for later. Requires scope: sms. |
| `GET` | `/api/v1/sms/scheduled` | List scheduled SMS. Requires scope: sms. |
| `DELETE` | `/api/v1/sms/scheduled/:id` | Cancel scheduled SMS. Requires scope: sms. |
| `GET` | `/api/v1/sms/threads/:phoneNumber` | Get SMS thread by phone number (messages + resolved contact). Requires scope: sms. |
| `PUT` | `/api/v1/sms/threads/:phoneNumber` | Mark inbound unread messages in the thread as read. Body: { unread: false }. Requires scope: sms. |

#### `POST /api/v1/sms`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `to` | string | Yes | Recipient phone (E.164 preferred) |
| `body` | string | Yes | Plain-text SMS. Aliases: message, text. Not html/bodyHtml. |
| `fromNumber` | string | No | Workspace Twilio number (alias: from). Defaults to workspace default. |
| `linkPersonId` | string | No | — |
| `linkCompanyId` | string | No | — |
| `linkDealId` | string | No | — |
| `isAlphanumericSender` | boolean | No | — |

#### `POST /api/v1/sms/bulk`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `body` | string | Yes | Shared SMS text. Aliases: message, text. |
| `recipients` | array | Yes | Phone strings and/or { phone, personId?, companyId? } objects (to is accepted as phone alias) |
| `fromNumber` | string | No | Alias: from |
| `immediate` | boolean | No | If true and recipients <= 50, send synchronously; otherwise queue |

#### `POST /api/v1/sms/schedule`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `to` | string | Yes | — |
| `body` | string | Yes | Aliases: message, text |
| `scheduledAt` | string | Yes | Future ISO datetime |
| `fromNumber` | string | No | Alias: from |
| `linkPersonId` | string | No | — |
| `linkCompanyId` | string | No | — |
| `linkDealId` | string | No | — |
| `isAlphanumericSender` | boolean | No | — |

#### `PUT /api/v1/sms/threads/:phoneNumber`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unread` | boolean | No | Set false to mark read |

### SMS Templates

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/sms-templates` | List SMS templates |

### Webhook Subscriptions

Subscribe to CRM events via outbound webhooks. See the Webhooks section for available events.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/webhooks/subscriptions` | List webhook subscriptions |
| `POST` | `/api/v1/webhooks/subscriptions` | Create webhook subscription |
| `GET` | `/api/v1/webhooks/subscriptions/:id` | Get subscription (secret masked) |
| `PUT` | `/api/v1/webhooks/subscriptions/:id` | Update subscription |
| `DELETE` | `/api/v1/webhooks/subscriptions/:id` | Delete subscription |
| `POST` | `/api/v1/webhooks/zapier` | Zapier inbound webhook — creates person, lead, and deal in one request (Bearer API key) |

#### `POST /api/v1/webhooks/subscriptions`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | — |
| `events` | string[] | Yes | Array of event names to subscribe to |
| `name` | string | No | — |
| `active` | boolean | No | — |

#### `POST /api/v1/webhooks/zapier`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `firstName` | string | No | — |
| `lastName` | string | No | — |
| `email` | string | No | — |
| `phone` | string | No | — |
| `leadTitle` | string | No | — |
| `dealTitle` | string | No | — |
| `dealValue` | number | No | — |
| `source` | string | No | — |

### CRM Connect

Lookup and connect CRM data with external systems

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/crm-connect/lookup` | Look up entity by phone/email |
| `POST` | `/api/v1/crm-connect/calls` | Log or connect call events for integration |

### Workspace offer settings

Workspace sender profile and offer defaults used by {{company.*}} and {{bank.*}} placeholders, plus communication, numbering, units, and contract defaults. PATCH merges nested objects and replaces arrays. Write requires a workspace admin or owner (write scope). Products, offer types, templates, and offers are separate resources.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/workspace/offer-settings` | Read workspace offer settings. Creates defaults on first read. |
| `PATCH` | `/api/v1/workspace/offer-settings` | Update companyInfo (name, cvr, address.street/postalCode/city, phone, email, website, logo), bankInfo (bankName, registrationNumber, accountNumber, iban, swift), defaults, branding, emailSettings, smsSettings, acceptanceSettings, trackingSettings, sequenceSettings, orderConfirmationSettings, numbering, contractSettings, defaultTerms, defaultPaymentTerms, lineItemUnits, billingFrequencies, setupCompleted. Admin or owner. |

#### `PATCH /api/v1/workspace/offer-settings`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `companyInfo` | object | No | Sender profile. Maps to {{company.name}}, {{company.cvr}}, {{company.phone}}, {{company.email}}, {{company.street}}, {{company.postalCode}}, {{company.city}}, {{company.logo}} |
| `bankInfo` | object | No | bankName, registrationNumber, accountNumber, iban, swift. Maps to {{bank.name}}, {{bank.reg}}, {{bank.account}}, {{bank.iban}}, {{bank.swift}} |
| `defaults` | object | No | vatRate, currency, defaultValidityDays, paymentTerms, defaultTemplateId, defaultOfferTypeId |
| `numbering` | object | No | { prefix, nextNumber } |
| `lineItemUnits` | array | No | [{ value, label, sortOrder }]. Replaces the list. |
| `billingFrequencies` | array | No | Replaces workspace billing periods. |

### Offers (quotes/proposals)

Create draft offers linked to offer types and templates. The visual layout comes from the template (native elements, not a HTML page on the offer). Prices are lineItems, rendered by the template line_items and totals elements. Send creates an immutable snapshot and public link.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/offers` | List offers |
| `POST` | `/api/v1/offers` | Create offer |
| `GET` | `/api/v1/offers/:id` | Get offer (by _id or publicId) |
| `PUT` | `/api/v1/offers/:id` | Update draft offer |
| `DELETE` | `/api/v1/offers/:id` | Delete draft offer |
| `POST` | `/api/v1/offers/:id/send` | Send offer — snapshot, activity, automations, offer.sent webhook. Returns publicId. |
| `POST` | `/api/v1/offers/:id/cancel` | Cancel offer |
| `POST` | `/api/v1/offers/:id/send-email` | Send or resend offer email to customer |
| `POST` | `/api/v1/offers/:id/send-sms` | Send or resend offer link via SMS |
| `GET` | `/api/v1/offers/:id/preview-token` | Preview URL for draft offers (short-lived token) or public link when sent |

#### `POST /api/v1/offers`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | Yes | — |
| `offerTypeId` | ObjectId | No | — |
| `templateId` | ObjectId | No | — |
| `companyRef` | ObjectId | No | — |
| `personRef` | ObjectId | No | — |
| `dealRef` | ObjectId | No | — |
| `quoteInput` | object | No | { contact, address, facts, calculatorInputs } |
| `lineItems` | array | No | Preferred write field for offer lines. Each item: name (or description), quantity, unitPrice, optional unit/vatRate. Server generates id and totals. |
| `calculatorResult` | object | No | Readback/result shape { lineItems, subtotal, vatAmount, total }. lineItems without id/name are normalized on write (same as top-level lineItems). |
| `validUntil` | Date | No | — |

#### `PUT /api/v1/offers/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | — |
| `templateId` | ObjectId | No | — |
| `offerTypeId` | ObjectId | No | — |
| `quoteInput` | object | No | — |
| `lineItems` | array | No | Replace draft offer lines. Same shape as POST. |
| `calculatorResult` | object | No | — |

#### `POST /api/v1/offers/:id/send-email`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `emailTo` | string | No | Optional recipient override |
| `senderAccountId` | ObjectId | No | Optional sender email account |
| `templateId` | ObjectId | No | Optional email template |

#### `POST /api/v1/offers/:id/send-sms`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `phoneTo` | string | No | Optional phone override |

### Contracts (e-signature envelopes)

Contracts are offers with kind=contract and a multi-signer envelope. Send delivers per-signer links (sequential or parallel). Status accepted means fully signed. Signer tokens are never exposed.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/contracts` | List contracts |
| `POST` | `/api/v1/contracts` | Create draft contract with signers |
| `GET` | `/api/v1/contracts/:id` | Get contract incl. envelope status and signer progress |
| `POST` | `/api/v1/contracts/:id/send` | Send to signers (snapshot + links). Webhooks: contract.sent, contract.signed, contract.completed, contract.declined, contract.expired, contract.voided |
| `POST` | `/api/v1/contracts/:id/void` | Void (annullér) a sent contract |

#### `POST /api/v1/contracts`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | — |
| `templateId` | ObjectId | No | Contract template (kind=contract) |
| `companyRef` | ObjectId | No | — |
| `personRef` | ObjectId | No | — |
| `dealRef` | ObjectId | No | — |
| `signers` | array | Yes | [{ name, email, phone?, title?, role?: 'signer'|'cc' }] in signing order |
| `mode` | string | No | sequential (default) | parallel |
| `signatureLevel` | string | No | standard (default) | mitid |
| `deadlineAt` | Date | No | Signing deadline |
| `contract` | object | No | { startDate, endDate } agreement period |
| `lineItems` | array | No | Optional price lines |
| `sourceOfferId` | ObjectId | No | Accepted offer this contract was generated from |

#### `POST /api/v1/contracts/:id/send`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `channels` | object | No | { email: true, sms: false } |
| `message` | string | No | — |

#### `POST /api/v1/contracts/:id/void`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `reason` | string | No | — |
| `notifySigners` | boolean | No | — |

### Offer Templates (tilbudsskabeloner)

Visual page-builder templates with pages[], headerElements, footerElements, and branding. Build pages from native elements (text, heading, image, table, columns, line_items, totals, button, divider, spacer, signer fields). See ?section=offerTemplates&subsection=nativeElements before writing layout. type html is a last resort.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/offer-templates` | List templates (summary without full pages) |
| `POST` | `/api/v1/offer-templates` | Create template with full layout (admin scope) |
| `GET` | `/api/v1/offer-templates/:id` | Get full template including pages and elements |
| `PATCH` | `/api/v1/offer-templates/:id` | Update template (admin scope) |
| `DELETE` | `/api/v1/offer-templates/:id` | Delete template (admin scope) |
| `POST` | `/api/v1/offer-templates/:id/duplicate` | Duplicate template (admin scope) |

#### `POST /api/v1/offer-templates`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `pages` | array | No | Page[] with native elements. Catalog: ?section=offerTemplates&subsection=nativeElements. Do not default pages to html. |
| `headerElements` | array | No | — |
| `footerElements` | array | No | — |
| `branding` | object | No | colors, typography, logo |
| `defaultTerms` | string | No | — |
| `defaultPaymentTerms` | string | No | — |
| `isDefault` | boolean | No | — |

### Offer Types (Prisregler)

Prisregler: seller questions (requiredInputFields, incl. computed m² fields) and Hvis … så … pricing rules. Workflow: rule-reference → draft → validate → simulate → POST/PATCH. Full guide: ?section=pricingRules.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/offer-types` | List prisregler (offer types) |
| `POST` | `/api/v1/offer-types` | Create prisregler (admin). Validated like /offer-types/validate; 400 returns details[] with path + error. Rule id/order/phase are filled in when omitted. |
| `GET` | `/api/v1/offer-types/rule-reference` | Builder vocabulary for this workspace: products (_id, name, unitPrice), subjects + keys (BBR only when the module is on), operators, action types, expression ops |
| `GET` | `/api/v1/offer-types/rule-templates` | Starter rules (Start fra skabelon) with fresh ids, the fields each needs, and placeholders to fill (product ids) |
| `POST` | `/api/v1/offer-types/validate` | Read-only. Validate a draft { requiredInputFields, pricingRules } and get errors[], warnings[] and Danish ruleSummaries. Nothing is saved. |
| `POST` | `/api/v1/offer-types/simulate` | Read-only dry run of the engine. Returns lines, subtotal, computedFields, warnings, blocks and trace. Nothing is saved. |
| `GET` | `/api/v1/offer-types/:id` | Get prisregler. ?describe=true adds ruleSummaries (Danish Hvis … så … text per rule) |
| `PATCH` | `/api/v1/offer-types/:id` | Update prisregler (admin). pricingRules merges with existing VAT/currency settings; pricingRules.rules and requiredInputFields replace the whole array. Validated when either is sent. |
| `DELETE` | `/api/v1/offer-types/:id` | Delete prisregler (admin) |

#### `GET /api/v1/offer-types`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | number | No | — | — |
| `limit` | number | No | — | max 100 |
| `includeInactive` | boolean | No | — | true to include inactive |

#### `POST /api/v1/offer-types`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `description` | string | No | — |
| `isDefault` | boolean | No | Preselect on new offers |
| `defaultTemplateId` | ObjectId | No | OfferTemplate _id |
| `requiredInputFields` | array | No | [{ fieldId, label, type: number|text|select|checkbox|radio|slider|computed, unit?, options?, required?, expression? }]. See ?section=pricingRules&subsection=fieldTypes |
| `pricingRules` | object | No | { rules: [{ name, when: { all, any }, then }], vatRate?, currency?, vatDisplayMode? }. See ?section=pricingRules&subsection=actionTypes |

#### `GET /api/v1/offer-types/rule-templates`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `industry` | string | No | — | IT | Håndværk | Generelt |

#### `POST /api/v1/offer-types/validate`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `requiredInputFields` | array | No | — |
| `pricingRules` | object | No | — |

#### `POST /api/v1/offer-types/simulate`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `offerTypeId` | ObjectId | No | Saved prisregler (or send offerType) |
| `offerType` | object | No | Unsaved draft { requiredInputFields, pricingRules } |
| `fields` | object | No | { fieldId: value } answers |
| `lines` | array | No | [{ offerProductId, quantity, unitPrice? } | { name, unitPrice, quantity }] |
| `customer` | object | No | { industry, country, postalCode, isExisting } |
| `offer` | object | No | { manualDiscountPercent } |

### Products & Catalog

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/products` | List products |
| `POST` | `/api/v1/products` | Create product |
| `GET` | `/api/v1/products/:id` | Get product |
| `PUT` | `/api/v1/products/:id` | Update product |
| `DELETE` | `/api/v1/products/:id` | Delete product |
| `GET` | `/api/v1/catalog` | List catalog items |
| `POST` | `/api/v1/catalog` | Create catalog item |
| `GET` | `/api/v1/catalog/:id` | Get catalog item |
| `PUT` | `/api/v1/catalog/:id` | Update catalog item |
| `DELETE` | `/api/v1/catalog/:id` | Delete catalog item |

### Projects

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/projects` | List projects |
| `POST` | `/api/v1/projects` | Create project |
| `GET` | `/api/v1/projects/:id` | Get project |
| `PUT` | `/api/v1/projects/:id` | Update project. customFields is a partial key→value merge (definitions: GET /api/v1/project-custom-fields?entityType=project). |
| `DELETE` | `/api/v1/projects/:id` | Delete project |
| `POST` | `/api/v1/projects/:id/move` | Move project to different stage |
| `GET` | `/api/v1/projects/:id/stats` | Get project statistics |
| `GET` | `/api/v1/projects/:id/tasks` | List project tasks |
| `POST` | `/api/v1/projects/:id/tasks` | Create project task |
| `GET` | `/api/v1/projects/tasks/:taskId` | Get project task |
| `PUT` | `/api/v1/projects/tasks/:taskId` | Update project task. Send stage (from GET /api/v1/task-flow) or status. status done sets stage to completedStageId. The task list Status column and done checkbox read stage, not status alone. customFields is a partial key→value merge. |
| `DELETE` | `/api/v1/projects/tasks/:taskId` | Delete project task |
| `POST` | `/api/v1/projects/tasks/:taskId/move` | Move a project task to another task-flow stage. stage or status is enough. status done sets stage to completedStageId from GET /api/v1/task-flow. |
| `GET` | `/api/v1/task-flow` | Get the workspace task flow (opgavestatusser), including stages and completedStageId. Use completedStageId as stage when marking a project task Færdig so the list Status column and done checkbox update. |
| `GET` | `/api/v1/projects/tasks/:taskId/comments` | List comments on a project task, including internal team notes. Each item has internal (boolean) and visibility (customer or internal). Internal notes are never shown in the customer portal. Task must belong to a project. |
| `POST` | `/api/v1/projects/tasks/:taskId/comments` | Create a team comment on a project task. Without internal, it is the same thread as CRM Kommentarer and the customer portal when comments are enabled. With internal: true or visibility: internal, the note is team-only: hidden in the customer portal and not emailed to the customer. |
| `PATCH` | `/api/v1/projects/tasks/:taskId/comments/:commentId` | Update a project task comment (admin/owner). Change content and/or move between customer thread and internal notes via internal/visibility. Requires content and/or internal. |
| `DELETE` | `/api/v1/projects/tasks/:taskId/comments/:commentId` | Delete a project task comment (admin/owner). |
| `GET` | `/api/v1/projects/:id/time-entries` | List time entries on a project. |
| `POST` | `/api/v1/projects/:id/time-entries` | Register time on a project (and optionally a task). Uses the same rate, clip-card, and min-words rules as the UI timer. |
| `PUT` | `/api/v1/projects/time-entries/:entryId` | Update time entry |
| `DELETE` | `/api/v1/projects/time-entries/:entryId` | Delete time entry |
| `GET` | `/api/v1/projects/:id/expenses` | List expenses |
| `POST` | `/api/v1/projects/:id/expenses` | Create expense |

#### `PUT /api/v1/projects/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | — |
| `description` | string | No | — |
| `status` | string | No | — |
| `priority` | string | No | — |
| `owner` | string | No | — |
| `tags` | string[] | No | — |
| `customFields` | object | No | Partial key → typed value merge |

#### `PUT /api/v1/projects/tasks/:taskId`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | — |
| `description` | string | No | — |
| `status` | string | No | Coarse status. done also sets stage to completedStageId. |
| `stage` | number | No | Stage id from GET /api/v1/task-flow. Wins over status when both are sent. Use completedStageId to mark Færdig. |
| `priority` | string | No | — |
| `owner` | string | No | User ID |
| `assignees` | string[] | No | — |
| `startDate` | string | No | — |
| `deadline` | string | No | — |
| `estimatedHours` | number | No | — |
| `taskType` | string | No | — |
| `billable` | boolean | No | — |
| `tags` | string[] | No | — |
| `order` | number | No | — |
| `customFields` | object | No | Partial key → typed value merge. Definitions: GET /api/v1/project-custom-fields?entityType=task |

#### `POST /api/v1/projects/tasks/:taskId/move`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stage` | number | No | Stage id from GET /api/v1/task-flow. Sets status (completedStageId becomes done). |
| `status` | string | No | Coarse status. Also sets stage. Ignored for status when stage is sent. |
| `order` | number | No | Order within the stage |

#### `GET /api/v1/projects/tasks/:taskId/comments`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `visibility` | string | No | — | Optional. customer returns the portal thread only. internal returns team notes only. Omit to return both. (customer, internal) |

#### `POST /api/v1/projects/tasks/:taskId/comments`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | Yes | Comment text. HTML is stripped. Max 5000 characters. |
| `internal` | boolean | No | True saves an internal team note. False or omitted is visible to the customer when portal comments are on. |
| `visibility` | string | No | customer (default) or internal. Same as internal: true when set to internal. Do not contradict the internal flag. |

#### `PATCH /api/v1/projects/tasks/:taskId/comments/:commentId`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | No | New text. HTML stripped. Max 5000 characters. |
| `internal` | boolean | No | true = internal team note; false = customer-visible thread |
| `visibility` | string | No | Same meaning as internal flag |

#### `GET /api/v1/projects/:id/time-entries`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `taskId` | string | No | — | Only entries attached to this project task |
| `page` | number | No | — | — |
| `limit` | number | No | — | — |

#### `POST /api/v1/projects/:id/time-entries`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `hours` | number | Yes | Hours to log. Can be combined with minutes. Must be greater than 0. |
| `minutes` | number | No | Optional extra minutes added to hours (e.g. 30 = 0.5 hours). |
| `taskId` | string | No | Optional project task to attach the time entry to. |
| `description` | string | No | Work description. Required when the project has timeEntryMinWords set. |
| `date` | string | No | ISO date of the work. Defaults to now. |
| `billable` | boolean | No | Default true. Non-billable entries still consume clip-card hours. |

### Bookings

Authenticated workspace API (API key). Create/cancel/reschedule/slots use the same pipeline as the public booking page. For a custom website UI without an API key, see GET /api/v1/docs?section=publicBooking.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/bookings` | List bookings. dateFrom/dateTo filter createdAt. startFrom/startTo filter startDateTime (when the meeting starts). |
| `POST` | `/api/v1/bookings` | Create booking (slot check, calendar, CRM, emails, automations, webhooks) |
| `GET` | `/api/v1/bookings/:id` | Get booking by ID or publicId |
| `POST` | `/api/v1/bookings/:id/cancel` | Cancel booking (calendar + notifications) |
| `POST` | `/api/v1/bookings/:id/approve` | Approve pending booking |
| `POST` | `/api/v1/bookings/:id/reject` | Reject pending booking |
| `POST` | `/api/v1/bookings/:id/reschedule` | Reschedule booking |
| `GET` | `/api/v1/bookings/available-slots` | Get available time slots for a booking config |
| `GET` | `/api/v1/booking-configs` | List booking configurations |
| `GET` | `/api/v1/booking-configs/:id` | Get booking configuration |
| `PATCH` | `/api/v1/booking-configs/:id` | Update booking configuration (admin scope) |

#### `GET /api/v1/bookings`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `status` | string | No | — | — |
| `dateFrom` | string | No | — | createdAt >= ISO |
| `dateTo` | string | No | — | createdAt <= ISO |
| `startFrom` | string | No | — | startDateTime >= ISO |
| `startTo` | string | No | — | startDateTime <= ISO |
| `sortBy` | string | No | — |  (startDateTime, endDateTime, createdAt, status) |
| `view` | string | No | — |  (summary, full) |

#### `POST /api/v1/bookings`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `configId` | string | Yes | — |
| `slotStart` | string | Yes | ISO datetime |
| `attendeeInfo` | object | Yes | { name, email, phone?, notes? } |
| `meetingTypeId` | string | No | — |
| `selectedAgentId` | string | No | — |
| `customFieldsData` | object | No | — |
| `invitedParticipants` | array | No | — |

#### `POST /api/v1/bookings/:id/approve`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `notes` | string | No | — |

#### `POST /api/v1/bookings/:id/reject`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `reason` | string | No | — |

#### `POST /api/v1/bookings/:id/reschedule`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `newSlotStart` | string | Yes | — |

#### `GET /api/v1/bookings/available-slots`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `configId` | string | Yes | — | — |
| `startDate` | string | Yes | — | — |
| `endDate` | string | Yes | — | — |
| `meetingTypeId` | string | No | — | — |

### Reports

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/reports/pipeline` | Pipeline report. byStage entries include stageId + stageName (stage keeps the name for back-compat). |
| `GET` | `/api/v1/reports/leads` | Leads report |
| `GET` | `/api/v1/reports/offers` | Offers report |
| `GET` | `/api/v1/reports/support` | Support report |
| `GET` | `/api/v1/reports/history` | History/activity report (daily counts) |
| `GET` | `/api/v1/reports/organization` | Organization overview |
| `GET` | `/api/v1/reports/users` | Users report |
| `GET` | `/api/v1/reports/users/:userId` | Individual user report |

#### `GET /api/v1/reports/history`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `stat` | string | Yes | — |  (deals, leads, activities, companies) |
| `startDate` | string | Yes | — | ISO date (aliases: dateFrom, from) |
| `endDate` | string | Yes | — | ISO date (aliases: dateTo, to) |
| `userId` | string | No | — | — |

### Lead Scoring

Rules that compute lead.score (0-100) on create/update. Score is not writable on POST/PUT /leads. Admin required to create/update/delete rules.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/lead-scoring/rules` | List lead scoring rules (sorted by sortOrder) |
| `POST` | `/api/v1/lead-scoring/rules` | Create scoring rule. Admin/owner required. |
| `PUT` | `/api/v1/lead-scoring/rules/:id` | Update scoring rule (partial). Admin/owner required. |
| `DELETE` | `/api/v1/lead-scoring/rules/:id` | Delete scoring rule. Admin/owner required. |

#### `POST /api/v1/lead-scoring/rules`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Unique rule name in the workspace |
| `description` | string | No | — |
| `field` | string | Yes | Eval field: hasEmail, hasPhone, hasValue, source, contactType, value, tags, customFields.<key>, contact.<field>, etc. |
| `condition` | string | Yes | — |
| `value` | mixed | No | Comparison value (omit for exists/not_exists). Arrays for in/not_in. |
| `points` | number | Yes | Points to add (positive) or subtract (negative). Total clamped 0-100. |
| `isActive` | boolean | No | — |
| `sortOrder` | number | No | — |

#### `PUT /api/v1/lead-scoring/rules/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | — |
| `description` | string | No | — |
| `field` | string | No | — |
| `condition` | string | No | — |
| `value` | mixed | No | — |
| `points` | number | No | — |
| `isActive` | boolean | No | — |
| `sortOrder` | number | No | — |

### Lead Routing

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/lead-routing/rules` | List lead routing rules |
| `POST` | `/api/v1/lead-routing/rules` | Create routing rule |
| `PUT` | `/api/v1/lead-routing/rules/:id` | Update routing rule |
| `DELETE` | `/api/v1/lead-routing/rules/:id` | Delete routing rule |

### Import & Export

Import requires API key scope `import`; export requires `export`. Neither is covered by `write` alone. Admin/owner role is also required.

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/import/companies` | Import companies from CSV/data. Requires scope: import. |
| `POST` | `/api/v1/import/people` | Import people. Requires scope: import. |
| `POST` | `/api/v1/import/leads` | Import leads. Requires scope: import. |
| `POST` | `/api/v1/import/deals` | Import deals. Requires scope: import. |
| `POST` | `/api/v1/import/validate` | Validate import data before importing. Requires scope: import. |
| `GET` | `/api/v1/import/history` | List import history. Requires scope: import. |
| `POST` | `/api/v1/export/leads` | Export leads (POST only). Body: limit (max 10000), page, skip, cursor (_id), sortBy, sortOrder, fields, dateFrom, dateTo, filters. total is workspace match count. Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/deals` | Export deals (POST only). Body: limit, page, skip, cursor, sortBy, sortOrder, fields, dateFrom, dateTo, filters. Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/people` | Export people (POST only). Body: limit, page, skip, cursor, sortBy, sortOrder, fields, dateFrom, dateTo, filters. Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/companies` | Export companies (POST only). Body: limit (max 10000), page, skip, cursor (_id of last row), sortBy, sortOrder, fields (any stored field), dateFrom, dateTo, filters. Returns total, hasMore, nextCursor. Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/lists/:listId` | Export list items (POST only). Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/custom-objects/:slug` | Export custom object records (POST only). Requires scope: export. Admin/owner role required. |

### Files & Folders

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/files` | List files |
| `POST` | `/api/v1/files` | Upload file |
| `GET` | `/api/v1/files/:id` | Get file metadata |
| `DELETE` | `/api/v1/files/:id` | Delete file |
| `POST` | `/api/v1/files/:id/link` | Link file to entity |
| `DELETE` | `/api/v1/files/:id/link` | Unlink file from entity |
| `GET` | `/api/v1/files/cloud/accounts` | List accessible Google Drive / OneDrive accounts |
| `GET` | `/api/v1/files/cloud/browse` | Browse or search cloud files (parentId, driveId, query, recent, roots=true for shared drives, withPath) |
| `POST` | `/api/v1/files/cloud/link` | Link cloud files to an entity |
| `POST` | `/api/v1/files/cloud/create` | Create a cloud document in Salesbase/<entity> and link it |
| `GET` | `/api/v1/folders` | List folders |
| `POST` | `/api/v1/folders` | Create folder |
| `DELETE` | `/api/v1/folders/:id` | Delete folder |

### Users (Team Members)

Manage workspace members

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/users` | List workspace members |
| `GET` | `/api/v1/users/:id` | Get user by ID |
| `PUT` | `/api/v1/users/:id` | Update user (name, phone) |

#### `GET /api/v1/users`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `search` | string | No | — | — |

#### `PUT /api/v1/users/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `firstName` | string | No | — |
| `lastName` | string | No | — |
| `phone` | string | No | — |
| `defaultUserPhone` | string | No | — |

### Support Tickets

Authenticated workspace API (API key) for the agent inbox. For a custom website chat UI without an API key, see GET /api/v1/docs?section=publicChat. Category is a workspace-defined string from the support portal Kategori field (GET /api/v1/support-tickets/categories). Public visitors use the portal form, not this API.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/support-tickets` | List support tickets |
| `GET` | `/api/v1/support-tickets/categories` | List ticket category options for this workspace (from support portal Felter → Kategori). Use these values in POST/PUT category. |
| `POST` | `/api/v1/support-tickets` | Create support ticket. Requires an existing support flow. category is a workspace-defined string (GET /categories). Defaults to the first portal category, or general. |
| `GET` | `/api/v1/support-tickets/:id` | Get support ticket by ID |
| `PUT` | `/api/v1/support-tickets/:id` | Update support ticket. customFields is a partial key→value merge. |
| `DELETE` | `/api/v1/support-tickets/:id` | Delete support ticket |
| `POST` | `/api/v1/support-tickets/:id/reply` | Add reply to ticket |
| `POST` | `/api/v1/support-tickets/:id/move` | Move ticket to kanban stage |
| `GET` | `/api/v1/support-tickets/:id/messages` | List ticket messages |
| `GET` | `/api/v1/support-tickets/:id/time-entries` | List time entries on ticket |
| `POST` | `/api/v1/support-tickets/:id/time-entries` | Create time entry on ticket |
| `GET` | `/api/v1/support-flow` | Get (or lazily create) the workspace's default support flow (kanban stages) |
| `GET` | `/api/v1/support-customers` | List support customers (grouped identity across tickets by person, email, or chat visitor) |
| `GET` | `/api/v1/support-customers/detail` | Get full detail for one support customer: tickets, time stats, and time entries |

#### `GET /api/v1/support-tickets`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `status` | string | No | — | — |
| `priority` | string | No | — | — |
| `assignee` | string | No | — | — |
| `category` | string | No | — | Workspace-defined category value |
| `search` | string | No | — | — |

#### `POST /api/v1/support-tickets`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | Yes | — |
| `description` | string | No | — |
| `priority` | string | No | — |
| `category` | string | No | Workspace-defined category. Use GET /api/v1/support-tickets/categories. |
| `assignee` | string | No | User ID |
| `company` | string | No | Company ID |
| `personRef` | string | No | Person ID |
| `tags` | string[] | No | — |

#### `PUT /api/v1/support-tickets/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | — |
| `description` | string | No | — |
| `priority` | string | No | — |
| `status` | string | No | — |
| `category` | string | No | Workspace-defined category. Use GET /api/v1/support-tickets/categories. |
| `assignee` | string | No | — |
| `tags` | string[] | No | — |
| `resolution` | string | No | — |
| `customFields` | object | No | Partial key → typed value merge |

#### `POST /api/v1/support-tickets/:id/reply`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | Yes | — |
| `type` | string | No | — |
| `visibility` | string | No | — |

#### `POST /api/v1/support-tickets/:id/move`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stageId` | number | Yes | — |
| `flowId` | string | No | Optional support flow ID |

#### `GET /api/v1/support-tickets/:id/messages`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `limit` | number | No | 50 | — |
| `visibility` | string | No | — |  (public, internal) |

#### `GET /api/v1/support-customers`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `search` | string | No | — | — |
| `q` | string | No | — | Alias for search |
| `sortBy` | string | No | lastActivityAt |  (lastActivityAt, totalTickets, openTickets, unread) |
| `sortOrder` | string | No | desc |  (asc, desc) |

#### `GET /api/v1/support-customers/detail`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | string | Yes | — |  (person, email, visitor) |
| `value` | string | Yes | — | personRef ID, email address, or chatVisitorRef ID depending on type |

### Filter Presets

Saved filter configurations for lists and views

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/filter-presets` | List filter presets |
| `POST` | `/api/v1/filter-presets` | Create filter preset |
| `GET` | `/api/v1/filter-presets/:id` | Get filter preset |
| `PUT` | `/api/v1/filter-presets/:id` | Update filter preset |
| `DELETE` | `/api/v1/filter-presets/:id` | Delete filter preset |

#### `GET /api/v1/filter-presets`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `area` | string | No | — | Filter by area (e.g. companies, leads, deals) |
| `page` | string | No | — | — |
| `limit` | string | No | — | — |

#### `POST /api/v1/filter-presets`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `area` | string | No | — |
| `columnFilters` | object | No | — |
| `filterTree` | object | No | — |
| `filterMode` | string | No | — |
| `sorting` | array | No | — |
| `visibility` | string | No | — |

### Email Signatures

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/email-signatures` | List email signatures |
| `POST` | `/api/v1/email-signatures` | Create signature |
| `GET` | `/api/v1/email-signatures/:id` | Get signature |
| `PUT` | `/api/v1/email-signatures/:id` | Update signature |
| `DELETE` | `/api/v1/email-signatures/:id` | Delete signature |

#### `POST /api/v1/email-signatures`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `content` | string | Yes | HTML content |
| `textContent` | string | No | — |

### Clip Cards (Prepaid Hours)

Manage prepaid service hour cards for projects

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/clip-cards` | List clip cards |
| `POST` | `/api/v1/clip-cards` | Create clip card |
| `GET` | `/api/v1/clip-cards/:id` | Get clip card |
| `PUT` | `/api/v1/clip-cards/:id` | Update clip card |
| `DELETE` | `/api/v1/clip-cards/:id` | Delete clip card |

#### `GET /api/v1/clip-cards`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `status` | string | No | — |  (active, depleted, expired, cancelled) |
| `projectId` | string | No | — | — |

#### `POST /api/v1/clip-cards`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `project` | string | Yes | Project ID |
| `title` | string | No | — |
| `totalHours` | number | Yes | — |
| `hourlyRate` | number | Yes | — |
| `allowOverdraft` | boolean | No | — |
| `overdraftRate` | number | No | — |
| `expiresAt` | string | No | ISO date |
| `notes` | string | No | — |

### Dashboard Layouts

Manage saved dashboard configurations

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/dashboard-layouts` | List dashboard layouts |
| `POST` | `/api/v1/dashboard-layouts` | Create layout |
| `GET` | `/api/v1/dashboard-layouts/:id` | Get layout |
| `PUT` | `/api/v1/dashboard-layouts/:id` | Update layout |
| `DELETE` | `/api/v1/dashboard-layouts/:id` | Delete layout |

#### `POST /api/v1/dashboard-layouts`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `layout` | object | No | — |
| `visibleComponents` | string[] | No | — |
| `chartWidgets` | array | No | — |
| `visibility` | string | No | — |

### Custom Objects

Workspace-defined object types and records. Schema/field mutations require admin scope; record CRUD uses write scope.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/custom-objects` | List custom object definitions |
| `POST` | `/api/v1/custom-objects` | Create custom object definition (admin scope). Sending color without useAccentNavIcon enables accent nav icons. |
| `GET` | `/api/v1/custom-objects/search` | Search records across all searchable objects |
| `GET` | `/api/v1/custom-objects/:slug` | Get definition with field schemas |
| `PATCH` | `/api/v1/custom-objects/:slug` | Update definition metadata (admin scope). Supports singular/plural/icon/color/sidebar fields plus titleField, favoriteFieldKeys, searchableFields, views, piiFields. Patching color without useAccentNavIcon enables accent nav icons. |
| `DELETE` | `/api/v1/custom-objects/:slug` | Delete object and all records (admin scope) |
| `POST` | `/api/v1/custom-objects/:slug/fields` | Add field (admin scope). Relation type is record_reference (not reference/relation). select/multi_select options must be { value, label } objects. See schema CustomObjectField for full type enum. |
| `PATCH` | `/api/v1/custom-objects/:slug/fields/:fieldSlug` | Update field (admin scope) |
| `DELETE` | `/api/v1/custom-objects/:slug/fields/:fieldSlug` | Delete field (admin scope) |
| `GET` | `/api/v1/custom-objects/:slug/records` | List records with pagination or cursor search |
| `POST` | `/api/v1/custom-objects/:slug/records` | Create record. Body is field-slug keys → values (flattened). Optional wrappers: { data: {…} } or { customFields: {…} }. Unknown or read-only slugs return 400. |
| `POST` | `/api/v1/custom-objects/:slug/records/bulk` | Bulk update records. Unknown field slugs in patch/operations return 400. |
| `GET` | `/api/v1/custom-objects/:slug/records/:id` | Get record by ID. Response is flattened field slugs (not nested under data). |
| `PATCH` | `/api/v1/custom-objects/:slug/records/:id` | Partial update. Body: field-slug map, or { data: {…} } / { customFields: {…} }. Unknown or read-only slugs return 400 with unknown/denied. Top-level avatar sets the record image: an https image URL (re-hosted) or a data:image/...;base64 URL (max 2 MB); empty clears it. |
| `PUT` | `/api/v1/custom-objects/:slug/records/:id` | Alias of PATCH (partial merge, not a full replace). |
| `DELETE` | `/api/v1/custom-objects/:slug/records/:id` | Delete record |
| `GET` | `/api/v1/custom-objects/:slug/records/:id/activity-feed` | Activity feed for a record |
| `GET` | `/api/v1/custom-objects/:slug/records/:id/related` | Related custom object records linked via native entity refs |
| `POST` | `/api/v1/export/custom-objects/:slug` | Export records (export + admin scope) |
| `POST` | `/api/v1/import/custom-objects/:slug` | Import records from rows array (import + admin scope) |

#### `POST /api/v1/custom-objects`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `slug` | string | Yes | — |
| `singular` | string | Yes | — |
| `plural` | string | Yes | — |
| `description` | string | No | — |
| `icon` | string | No | — |
| `color` | string | No | Accent hex. Implies useAccentNavIcon true unless that flag is set explicitly. |
| `useAccentNavIcon` | boolean | No | Show object color on sidebar icons. Defaults true when color is set. |

#### `GET /api/v1/custom-objects/search`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `q` | string | Yes | — | — |
| `limit` | number | No | 10 | — |

#### `PATCH /api/v1/custom-objects/:slug`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `color` | string | No | Accent hex. Implies useAccentNavIcon true unless that flag is set explicitly. |
| `useAccentNavIcon` | boolean | No | — |
| `icon` | string | No | — |
| `singular` | string | No | — |
| `plural` | string | No | — |
| `showInSidebar` | boolean | No | — |
| `titleField` | string | No | Field slug used as display title |
| `favoriteFieldKeys` | string[] | No | Up to 5 field slugs for previews |
| `searchableFields` | string[] | No | — |
| `views` | array | No | — |
| `piiFields` | string[] | No | — |

#### `POST /api/v1/custom-objects/:slug/fields`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `slug` | string | Yes | — |
| `label` | string | Yes | — |
| `type` | string | Yes | — |
| `options` | array | No | [{ value, label, color? }] for select/multi_select |
| `referenceType` | string | No | — |
| `referenceObject` | string | No | Custom object slug when referenceType=custom |
| `nativeEntity` | string | No | Native entity when referenceType=native |

#### `GET /api/v1/custom-objects/:slug/records`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | number | No | 1 | — |
| `limit` | number | No | 30 | — |
| `q` | string | No | — | Search query (alias: search) |
| `search` | string | No | — | Alias for q |
| `cursor` | string | No | — | Cursor for next page when searching |
| `sort` | string | No | — | Field slug to sort by |
| `dir` | string | No | desc |  (asc, desc) |

#### `POST /api/v1/custom-objects/:slug/records`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | No | — |
| `description` | string | No | — |

#### `POST /api/v1/custom-objects/:slug/records/bulk`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ids` | string[] | Yes | — |
| `patch` | object | Yes | Field slug → value |

#### `PATCH /api/v1/custom-objects/:slug/records/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | No | — |
| `description` | string | No | — |

#### `PUT /api/v1/custom-objects/:slug/records/:id`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | No | — |
| `description` | string | No | — |

#### `POST /api/v1/export/custom-objects/:slug`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `from` | string | No | — |
| `to` | string | No | — |
| `limit` | number | No | — |

#### `POST /api/v1/import/custom-objects/:slug`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `rows` | array | Yes | — |

### Calculators

Read calculator templates tied to offer types and run stateless price calculations.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/calculator-templates` | List calculator templates |
| `GET` | `/api/v1/calculator-templates/:id` | Get template with pricing rules |
| `POST` | `/api/v1/calculator-templates/:id/calculate` | Run calculation without persisting |
| `GET` | `/api/v1/calculator-submissions` | List calculator submissions |

#### `POST /api/v1/calculator-templates/:id/calculate`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `inputs` | object | Yes | — |

#### `GET /api/v1/calculator-submissions`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | string | No | — | — |
| `limit` | string | No | — | — |
| `templateId` | string | No | — | — |
| `offerId` | string | No | — | — |

### Integrations

Read integration connection status. OAuth connect flows remain in-app only.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/integrations` | List connected providers and sync health (no secrets) |
| `POST` | `/api/v1/integrations/:provider/sync` | Trigger manual sync (admin scope) |

#### `POST /api/v1/integrations/:provider/sync`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `fullSync` | boolean | No | — |

### Metrics

Read every statistic the dashboard and reports can show (calls, SMS, e-mail, pipeline, revenue, leads, offers, support, projects, custom objects, external series) and push your own time series. Custom-object metrics are keyed custom.{slug}.* and follow the object's view permission. Reads require the reports module; writes require write scope.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/metrics` | Catalog of metrics available in the workspace. Each entry: { key, label, description, category, format (integer|currency|percent|duration|decimal), mode (flow|snapshot), dimensions[], measureType }. Includes custom.{slug}.* families and external:{seriesKey} series. |
| `GET` | `/api/v1/metrics/query` | Query one metric. Returns total, optional period-over-period delta and a bucketed series. Snapshot metrics (mode snapshot) report the current state and ignore the window. |
| `POST` | `/api/v1/metrics/query` | Batch query up to 25 metrics in one call. Same fields per request as GET /api/v1/metrics/query. Results are returned in request order; an unknown or forbidden metric yields { metricKey, error, code } for that entry only. Add ?format=csv (or "format": "csv" in the body) to download all series as one CSV file. |
| `POST` | `/api/v1/metrics/points` | Idempotent upsert of daily points for an external series. The series appears in the catalog as external:{seriesKey} and can be shown on dashboards and reports. Max 5000 points per request. Requires write scope. |

#### `GET /api/v1/metrics`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `category` | string | No | — | Filter by category key (e.g. calls, sms, custom:{slug}, external) |

#### `GET /api/v1/metrics/query`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `metricKey` | string | Yes | — | Key from GET /api/v1/metrics |
| `from` | string | Yes | — | ISO date/datetime (inclusive) |
| `to` | string | No | — | ISO date/datetime (inclusive). Default now |
| `granularity` | string | No | — | Bucket size. Default day (day, week, month, quarter, year) |
| `dimension` | string | No | — | Group by one of the metric's dimensions (breakdown). Points get dimension + dimensionLabel |
| `compareTo` | string | No | — | Add compareTotal, compareSeries and delta { abs, pct } (previous, previousYear) |
| `tz` | string | No | — | IANA timezone for bucketing. Default Europe/Copenhagen |
| `format` | string | No | — | csv returns a UTF-8 CSV file with one row per point: metricKey, label, granularity, period (current|compare), bucket, dimension, dimensionValue, dimensionLabel, value, format. Default json (json, csv) |

#### `POST /api/v1/metrics/query`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `requests` | array | Yes | Array of { metricKey, from, to?, granularity?, dimension?, compareTo?, tz? } |
| `format` | string | No | Response format. Default json |

#### `POST /api/v1/metrics/points`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `seriesKey` | string | Yes | Stable series id (letters, digits, '_', '.', '-'). Stored as external:{seriesKey}. |
| `label` | string | No | Display name shown in pickers (set once or update any time) |
| `description` | string | No | Short help text for the picker |
| `format` | string | No | Number format. Default decimal |
| `points` | array | Yes | Array of { date (ISO), value (number), dimensions? (object) } |

### Project templates

Reusable project blueprints (tasks, milestones, portal/invoice defaults). Create a project with POST /api/v1/projects/from-template. On offer accept, set OfferType.createProjectOnAccept + projectTemplateId instead of an automation create_project action.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/project-templates` | List project templates. includeInactive=true includes soft-deleted (isActive=false). |
| `POST` | `/api/v1/project-templates` | Create a project template (requires projects create). |
| `GET` | `/api/v1/project-templates/:id` | Get one template |
| `PATCH` | `/api/v1/project-templates/:id` | Update template fields |
| `DELETE` | `/api/v1/project-templates/:id` | Soft-delete (isActive=false) |
| `POST` | `/api/v1/projects/from-template` | Instantiate a project from a template (tasks/milestones). Prefer OfferType createProjectOnAccept for accept-time creation. |

#### `GET /api/v1/project-templates`

**Query Parameters:**

| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `includeInactive` | boolean | No | — | — |

#### `POST /api/v1/project-templates`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | — |
| `description` | string | No | — |
| `flowRef` | string | No | ProjectFlow id |
| `taskTemplates` | array | No | [{ title, description?, stage?, priority?, … }] |
| `milestoneTemplates` | array | No | — |
| `projectData` | object | No | — |
| `portalSettings` | object | No | — |
| `isDefault` | boolean | No | — |

#### `POST /api/v1/projects/from-template`

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `templateId` | string | Yes | — |
| `title` | string | No | — |
| `company` | string | No | Company id |
| `personRef` | string | No | — |
| `offerData` | object | No | Optional { offerId, offerTitle, lineItems, dealRef, leadRef } |

---

## Data Schemas

Salesbase CRM entities and their field structures. All entities are workspace-scoped. Custom fields can be added to any entity type via the Custom Fields API.

### Company

A business entity. Can be linked to people, leads, and deals.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | Unique identifier |
| `name` | string | Yes | Company name |
| `cvr` | string | — | Danish CVR number (unique within workspace) |
| `email` | string | — | Primary email |
| `phone` | string | — | Primary phone |
| `address` | string | — | — |
| `city` | string | — | — |
| `zip` | string | — | — |
| `country` | string | — | — |
| `website` | string | — | — |
| `industry` | string | — | — |
| `employees` | number | — | — |
| `description` | string | — | — |
| `customFields` | object | — | Key → typed value (boolean/number/string). Responses never include customFieldsMap. |
| `tags` | ObjectId[] | — | Array of tag IDs |
| `owner` | ObjectId | — | Assigned user ID |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### People

A contact person. Can be linked to companies, leads, and deals.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `firstName` | string | Yes | — |
| `lastName` | string | — | — |
| `emails` | array | — | [{ email: string, label: string }] |
| `phones` | array | — | [{ phone: string, label: string }] |
| `title` | string | — | Job title |
| `description` | string | — | — |
| `companyIds` | ObjectId[] | — | Connected company IDs |
| `customFields` | object | — | Key → typed value (boolean/number/string). Responses never include customFieldsMap. |
| `tags` | ObjectId[] | — | — |
| `owner` | ObjectId | — | — |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### Lead

A sales lead. Links to a company and/or person, and can have deals.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `title` | string | Yes | Lead title (typically company/person name) |
| `email` | string | — | — |
| `phone` | string | — | — |
| `status` | string | — | Lead status (e.g. 'new', 'contacted', 'qualified', 'won', 'lost') |
| `source` | string | — | Where the lead came from |
| `score` | number | — | Lead score (0-100) |
| `value` | number | — | Estimated value |
| `notes` | string | — | — |
| `companyRef` | ObjectId | — | Connected company |
| `personRef` | ObjectId | — | Connected person |
| `customFields` | object | — | Key → typed value (boolean/number/string) |
| `tags` | ObjectId[] | — | — |
| `owner` | ObjectId | — | — |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### Deal

A deal/opportunity in a pipeline. Linked to a company, person, and/or lead.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `title` | string | Yes | — |
| `value` | number | — | Deal monetary value |
| `flow` | ObjectId | — | Pipeline (flow) ID |
| `stage` | number | — | Stage ID within the pipeline |
| `status` | string | — | — |
| `probability` | number | — | Win probability (0-100) |
| `priority` | string | — | — |
| `expectedCloseDate` | Date | — | — |
| `wonDate` | Date | — | Set when status is won (synced with won stage) |
| `lostDate` | Date | — | Set when status is lost (synced with lost stage) |
| `closeDate` | Date | — | Set with wonDate for won deals |
| `lostReason` | string | — | Dialer loss-reason value (e.g. no_budget). Copied from call outcomes when deals are created from dialer. |
| `company` | ObjectId | — | Connected company |
| `personRef` | ObjectId | — | Connected person |
| `leadRef` | ObjectId | — | Connected lead |
| `customFields` | object | — | Key → typed value (boolean/number/string) |
| `tags` | ObjectId[] | — | — |
| `owner` | ObjectId | — | — |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### Project

A project. Custom field definitions: GET /api/v1/project-custom-fields?entityType=project.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `title` | string | Yes | — |
| `description` | string | — | — |
| `status` | string | — | — |
| `priority` | string | — | — |
| `owner` | ObjectId | — | — |
| `company` | ObjectId | — | — |
| `personRef` | ObjectId | — | — |
| `dealRef` | ObjectId | — | — |
| `customFields` | object | — | Key → typed value (boolean/number/string). Partial merge on PUT. |
| `tags` | string[] | — | — |
| `workspace` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### Task

A task (standalone or on a project). Custom field definitions: GET /api/v1/project-custom-fields?entityType=task.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `title` | string | Yes | — |
| `description` | string | — | — |
| `status` | string | — | — |
| `stage` | number | — | — |
| `priority` | string | — | — |
| `project` | ObjectId | — | — |
| `owner` | ObjectId | — | — |
| `assignees` | ObjectId[] | — | — |
| `customFields` | object | — | Key → typed value (boolean/number/string). Partial merge on PUT. |
| `tags` | string[] | — | — |
| `workspace` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### Automation

An event-driven automation flow (see Automations section for full detail)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `description` | string | — | — |
| `isActive` | boolean | — | — |
| `nodes` | Node[] | — | ReactFlow nodes (see Automation docs) |
| `edges` | Edge[] | — | ReactFlow edges |
| `triggers` | Trigger[] | — | Extracted trigger configs for quick lookup |
| `executionSettings` | ExecutionSettings | — | — |
| `rateLimits` | RateLimits | — | — |
| `stats` | Stats | — | { totalRuns, successfulRuns, failedRuns, lastRunAt } |
| `createdBy` | ObjectId | — | — |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### Sequence

A multi-step outreach sequence (see Sequences section for full detail)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `description` | string | — | — |
| `isActive` | boolean | — | — |
| `nodes` | Node[] | — | ReactFlow nodes (see Sequence docs) |
| `edges` | Edge[] | — | — |
| `allowedEntityTypes` | string[] | — | ['Lead', 'People', 'Deal', 'Company'] |
| `exitConditions` | ExitConditions | — | — |
| `sendSettings` | SendSettings | — | — |
| `trackingSettings` | TrackingSettings | — | — |
| `executionSettings` | ExecutionSettings | — | — |
| `stats` | Stats | — | — |
| `createdBy` | ObjectId | — | — |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### Activity

An activity log entry (call, meeting, email, note, task)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `type` | string | — | — |
| `title` | string | — | — |
| `description` | string | — | — |
| `entityType` | string | — | Related entity type |
| `entityId` | ObjectId | — | Related entity ID |
| `dueDate` | Date | — | — |
| `completed` | boolean | — | — |
| `owner` | ObjectId | — | — |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |

### Tag

A label that can be applied to any entity

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `color` | string | — | — |
| `workspaceId` | ObjectId | — | — |

### WebhookSubscription

An outbound webhook subscription that fires on CRM events

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `url` | string | Yes | — |
| `events` | string[] | Yes | — |
| `name` | string | — | — |
| `active` | boolean | — | — |
| `secret` | string | — | HMAC signing secret (returned on creation only) |
| `workspaceId` | ObjectId | — | — |
| `createdAt` | Date | — | — |

### CustomObjectDefinition

A workspace-defined object type (schema). Managed via admin-scoped API key.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `slug` | string | Yes | URL-safe identifier, e.g. contracts |
| `singular` | string | Yes | — |
| `plural` | string | Yes | — |
| `description` | string | — | — |
| `icon` | string | — | — |
| `color` | string | — | Hex accent for sidebar |
| `searchable` | boolean | — | — |
| `showInSidebar` | boolean | — | — |
| `workspace` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### CustomObjectField

Field definition on a custom object. Types mirror Attio-style schema fields.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `slug` | string | Yes | — |
| `label` | string | Yes | — |
| `type` | string | — | Relation fields use type record_reference (not reference/relation). There is no textarea type; use text. multi_select not multiselect. |
| `isRequired` | boolean | — | — |
| `isUnique` | boolean | — | — |
| `options` | array | — | For select/multi_select: array of { value, label, color? } objects (bare strings are invalid and may 400/500) |
| `referenceObject` | string | — | Custom object slug when referenceType=custom |
| `referenceType` | string | — | — |
| `nativeEntity` | string | — | company, person, deal, lead, project, offer when referenceType=native |
| `order` | number | — | — |

### CustomObjectRecord

A record instance for a custom object. API responses flatten field values to top-level field-slug keys (plus id, _recordNumber, _displayTitle). Mongo stores values under data.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | ObjectId | — | Record id (also as _id) |
| `object` | ObjectId | — | CustomObjectDefinition _id |
| `_recordNumber` | number | — | Auto-increment per object type |
| `_displayTitle` | string | — | Computed display title |
| `(fieldSlug)` | mixed | — | Each writable field appears as a top-level key on the API response |
| `workspace` | ObjectId | — | — |
| `createdBy` | ObjectId | — | — |
| `updatedBy` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### OfferTemplate

Tilbudsskabelon page builder. Prefer native elements so the user can edit them in the editor. Full catalog: /api/v1/docs?section=offerTemplates&subsection=nativeElements. type html is a last resort.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `description` | string | — | — |
| `pages` | array | — | [{ id, order, title, elements[], settings }] |
| `headerElements` | array | — | Elements on every page header |
| `footerElements` | array | — | Elements on every page footer |
| `branding` | object | — | { colors, typography, logo } |
| `defaultTerms` | string | — | — |
| `defaultPaymentTerms` | string | — | — |
| `defaultValidity` | number | — | Days |
| `isDefault` | boolean | — | — |
| `isActive` | boolean | — | — |
| `defaultOfferTypeId` | ObjectId | — | Price rules selected automatically when this template is used on a new offer. Null clears the link. |
| `workspaceId` | ObjectId | — | — |

### EmailTemplate

Email template rendered from bodyHtml + bodyCss + layoutSettings (single document model). compiledHtml is the derived/legacy full document. Optional GrapesJS projectData.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `category` | string | — | — |
| `bodyHtml` | string | — | Body content with {{contact.firstName}} placeholders (preferred) |
| `bodyCss` | string | — | CSS rules applied to bodyHtml |
| `compiledHtml` | string | — | Legacy/derived full HTML document with placeholders |
| `plainText` | string | — | — |
| `metadata` | object | — | { subject, preheader } |
| `layoutSettings` | object | — | { width: full|fixed, align, containerWidth, textAlign, paddingTop/Right/Bottom/Left, backgroundColor, outerBackground, fullWidthBackground }. Deprecated aliases: layoutMode, useTableWrapper, containerAlign, outerBackgroundColor |
| `requiredVars` | array | — | — |
| `tags` | string[] | — | — |
| `workspace` | ObjectId | — | — |

### OfferType

Tilbudstype — pricing rules, BBR mapping, default template, CRM automations on accept/submit.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `slug` | string | — | — |
| `defaultTemplateId` | ObjectId | — | — |
| `requiredInputFields` | array | — | Input and computed fields. type computed uses expression { left, op, right, round } |
| `pricingRules` | object | — | v2: schemaVersion 2, rules[]. Actions may use mode expression and quantity.kind expression (A op B: mul add sub div pctOf). Legacy: tieredPricing, conditionalRules, packages, globalSurcharges |
| `offerSettings` | object | — | OTP, deal/sequence on accept/submit |
| `customPlaceholders` | array | — | → {{custom.fieldId}} in templates. inputType text, date, richText. options turns the field into a dropdown |
| `isActive` | boolean | — | — |
| `workspaceId` | ObjectId | — | — |

### OfferProduct

Product/service for offers. pricingType: fixed | advanced (tiered pricing).

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `unitPrice` | number | — | — |
| `unit` | string | — | — |
| `pricingType` | string | — | — |
| `tieredPricing` | array | — | — |
| `conditionalRules` | array | — | — |
| `variants` | array | — | — |
| `category` | string | — | — |
| `isActive` | boolean | — | — |
| `workspaceId` | ObjectId | — | — |

### Custom Field Types

Custom fields support these data types

| Type | Description |
|------|-------------|
| `text` | Free-text string |
| `number` | Numeric value |
| `date` | ISO date string |
| `checkbox` | Boolean (true/false) |
| `select` | Single-select from predefined options |
| `multi_select` | Multi-select from predefined options |
| `url` | URL string |
| `email` | Email address |
| `phone` | Phone number (stored as text) |
| `textarea` | Multi-line text |

> Lead and Deal custom fields use Map storage (key → value). Company and People use array storage ([{ name, value }]). When creating via API, both formats are accepted and auto-converted.

---

## Automations

Automations are event-driven flows that execute actions when specific triggers fire. Each automation has exactly one trigger node and one or more action/condition/delay/AI nodes connected by edges. Automations are stored as ReactFlow graphs (nodes + edges) and executed by a cron-based step processor.

### Concepts

- **flow:** An automation flow is a directed graph of nodes connected by edges. It always starts with a single trigger node.
- **trigger:** The entry point — fires when a specific event occurs (e.g. lead_created, deal_stage_changed).
- **action:** Performs an operation. Action settings live under node.data.config.
- **condition:** Branches via sourceHandle 'true'|'false'.
- **delay:** Pauses execution (rounded to 5-minute cron intervals).
- **ai:** AI model step; output as {{ai_text}} / {{ai_json.*}}.
- **lookup:** Queries records; sourceHandle found/empty/error. mode=all fans out one child execution per match.
- **edge:** Condition/AI condition: true|false. Lookup: found|empty|error. Else null.

### Automation Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/automations` | List all automations in the workspace |
| `POST` | `/api/v1/automations` | Create a new automation |
| `GET` | `/api/v1/automations/:id` | Get a single automation by ID |
| `PUT` | `/api/v1/automations/:id` | Update an automation |
| `DELETE` | `/api/v1/automations/:id` | Delete an automation. Fails if there are running executions. |
| `POST` | `/api/v1/automations/:id/toggle` | Toggle automation active/inactive |
| `GET` | `/api/v1/automations/:id/executions` | List executions for an automation |
| `GET` | `/api/v1/automations/executions/:executionId` | Get a single execution with full step log |
| `GET` | `/api/v1/automations/stats` | Get aggregate automation statistics for the workspace |
| `POST` | `/api/v1/automations/:id/duplicate` | Duplicate an automation (creates inactive copy) |
| `POST` | `/api/v1/automations/:id/test` | Run automation in test mode with sample context |
| `POST` | `/api/v1/automations/:id/trigger` | Manually trigger an active automation |
| `POST` | `/api/v1/automations/executions/:executionId/cancel` | Cancel a running automation execution |

### Node Structure

Every node in the flow has this base structure:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Unique node ID. Convention: {type}_{timestamp}_{index} |
| `type` | string | Yes | Canvas node type |
| `position` | object | Yes | Canvas position { x, y }. Vertical flows: x=500, increment y by 200 per row. |
| `data` | object | Yes | Node config. Triggers: triggerType + config. Actions: actionType + config. |

### Edge Structure

Edges connect nodes in the flow. Branching nodes require sourceHandle.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Unique edge ID, e.g. 'edge_trigger_1-action_1' |
| `source` | string | Yes | Source node ID |
| `target` | string | Yes | Target node ID |
| `sourceHandle` | string|null | No | Branching handle. Condition / AI condition: 'true' | 'false'. Lookup: 'found' | 'empty' | 'error'. null for non-branching nodes. |
| `targetHandle` | string|null | No | Target handle (usually null) |

### Trigger Types

Each automation must have exactly ONE trigger node. Set data.triggerType and optional data.config on the trigger node.

#### `lead_created` — Lead oprettet

Starter når et nyt lead oprettes. *(category: leads)*

**Placeholders:** `lead.*`, `person.*`, `company.*`, `owner.*`

#### `lead_updated` — Lead opdateret

Starter når et lead opdateres. *(category: leads)*

**Placeholders:** `lead.*`, `person.*`, `company.*`, `owner.*`

#### `company_updated` — Virksomhed opdateret

Starter når en virksomhed opdateres. *(category: general)*

**Placeholders:** `company.*`, `owner.*`

#### `person_updated` — Person opdateret

Starter når en person opdateres. *(category: general)*

**Placeholders:** `person.*`, `company.*`, `owner.*`

#### `deal_created` — Deal oprettet

Starter når en deal oprettes. *(category: deals)*

**Placeholders:** `deal.*`, `company.*`, `person.*`, `owner.*`

#### `deal_updated` — Deal opdateret

Starter når en deal opdateres. *(category: deals)*

**Placeholders:** `deal.*`, `company.*`, `person.*`, `owner.*`

#### `deal_won` — Deal vundet

Starter når en deal markeres som vundet. *(category: deals)*

**Placeholders:** `deal.*`, `company.*`, `person.*`, `owner.*`

#### `deal_lost` — Deal tabt

Starter når en deal markeres som tabt. *(category: deals)*

**Placeholders:** `deal.*`, `company.*`, `person.*`, `owner.*`

#### `deal_stage_changed` — Deal stage ændret

Starter når en deal flyttes til et nyt stage. *(category: deals)*

**Config:**

- `stageId` (string): Begræns til et bestemt stage (valgfrit — tom = alle stage-skift)

**Placeholders:** `deal.*`, `company.*`, `person.*`, `owner.*`

#### `call_completed` — Opkald afsluttet

Starter når et opkald afsluttes. *(category: calls)*

**Placeholders:** `call.*`, `lead.*`, `deal.*`, `owner.*`

#### `call_answered` — Opkald besvaret

Starter når et opkald bliver besvaret. *(category: calls)*

**Placeholders:** `call.*`, `lead.*`, `deal.*`, `owner.*`

#### `email_received` — Email modtaget

Starter når en email modtages. *(category: email)*

**Placeholders:** `email.*`, `lead.*`, `person.*`

#### `email_opened` — Email åbnet

Starter når en sendt email åbnes. *(category: email)*

**Placeholders:** `email.*`, `lead.*`, `person.*`

#### `email_replied` — Email besvaret

Starter når en email besvares. *(category: email)*

**Placeholders:** `email.*`, `lead.*`, `person.*`

#### `sms_received` — SMS modtaget

Starter når en SMS modtages. *(category: sms)*

**Config:**

- `keyword` (string): Valgfrit keyword-filter — kun beskeder der indeholder dette ord udløser

**Placeholders:** `sms.*`, `lead.*`, `person.*`

#### `sms_replied` — SMS besvaret

Starter når en SMS besvares. *(category: sms)*

**Config:**

- `keyword` (string): Valgfrit keyword-filter

**Placeholders:** `sms.*`, `lead.*`, `person.*`

#### `event_created` — Begivenhed oprettet

Starter når en kalenderbegivenhed oprettes. *(category: calendar)*

**Placeholders:** `event.*`, `owner.*`

#### `agent_field_change` — Agent: feltændring

Starter når en AI-agent ændrer et felt. *(category: agents)*

**Config:**

- `agentId` (string): Hvilken agent

**Placeholders:** `agent.*`, `company.*`, `person.*`, `owner.*`

#### `agent_new_company` — Agent: ny virksomhed

Starter når en AI-agent opretter en ny virksomhed. *(category: agents)*

**Config:**

- `agentId` (string): Hvilken agent

**Placeholders:** `agent.*`, `company.*`, `owner.*`

#### `agent_any` — Agent: enhver hændelse

Starter ved enhver AI-agent-hændelse. *(category: agents)*

**Placeholders:** `agent.*`, `company.*`, `person.*`, `owner.*`

#### `sales_analytics_done` — Salgsanalyse færdig

Starter når en salgsanalyse er færdig. *(category: analytics)*

**Placeholders:** `call.*`, `analytics.*`, `owner.*`

#### `transcription_ready` — Transskription klar

Starter når en opkaldstransskription er klar. *(category: analytics)*

**Placeholders:** `call.*`, `transcript.*`

#### `offer_created` — Tilbud oprettet

Starter når et tilbud oprettes. *(category: offers)*

**Placeholders:** `offer.*`, `deal.*`, `company.*`

#### `offer_sent` — Tilbud sendt

Starter når et tilbud sendes. *(category: offers)*

**Placeholders:** `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `offer_viewed` — Tilbud set

Starter når et tilbud åbnes af modtageren. *(category: offers)*

**Placeholders:** `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `offer_accepted` — Tilbud accepteret

Starter når et tilbud accepteres. *(category: offers)*

**Placeholders:** `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `offer_rejected` — Tilbud afvist

Starter når et tilbud afvises. *(category: offers)*

**Placeholders:** `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `offer_expired` — Tilbud udløbet

Starter når et tilbud udløber. *(category: offers)*

**Placeholders:** `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `offer_pdf_downloaded` — Tilbuds-PDF hentet

Starter når tilbuds-PDF'en downloades. *(category: offers)*

**Placeholders:** `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_sent` — Kontrakt sendt

Starter når en kontrakt sendes til underskrift. *(category: general)*

**Placeholders:** `contract.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_viewed` — Kontrakt åbnet

Starter når en underskriver åbner kontrakten. *(category: general)*

**Placeholders:** `contract.*`, `signer.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_signed` — Underskrift modtaget

Starter hver gang en underskriver har underskrevet. *(category: general)*

**Placeholders:** `contract.*`, `signer.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_completed` — Kontrakt underskrevet

Starter når alle parter har underskrevet. *(category: general)*

**Placeholders:** `contract.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_declined` — Kontrakt afvist

Starter når en underskriver afviser kontrakten. *(category: general)*

**Placeholders:** `contract.*`, `signer.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_expired` — Underskriftsfrist udløbet

Starter når fristen for underskrift udløber. *(category: general)*

**Placeholders:** `contract.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_voided` — Kontrakt annulleret

Starter når en kontrakt annulleres. *(category: general)*

**Placeholders:** `contract.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `contract_period_ending` — Kontrakt udløber snart

Starter når aftaleperioden nærmer sig sin slutdato. *(category: general)*

**Placeholders:** `contract.*`, `offer.*`, `deal.*`, `company.*`, `owner.*`

#### `booking_created` — Booking oprettet

Starter når en booking oprettes. *(category: bookings)*

**Config:**

- `bookingConfigId` (string): Begræns til en bestemt bookingside (valgfrit)

**Placeholders:** `booking.*`, `person.*`

#### `booking_cancelled` — Booking aflyst

Starter når en booking aflyses. *(category: bookings)*

**Config:**

- `bookingConfigId` (string): Bookingside (valgfrit)

**Placeholders:** `booking.*`, `person.*`

#### `booking_rescheduled` — Booking flyttet

Starter når en booking flyttes. *(category: bookings)*

**Config:**

- `bookingConfigId` (string): Bookingside (valgfrit)

**Placeholders:** `booking.*`, `person.*`

#### `booking_approved` — Booking godkendt

Starter når en booking godkendes. *(category: bookings)*

**Placeholders:** `booking.*`, `person.*`

#### `booking_rejected` — Booking afvist

Starter når en booking afvises. *(category: bookings)*

**Placeholders:** `booking.*`, `person.*`

#### `calculator_submission` — Beregner indsendt

Starter når en beregner indsendes. *(category: calculators)*

**Config:**

- `calculatorTemplateId` (string): Begræns til en bestemt beregner (valgfrit)

**Placeholders:** `calculator.*`, `lead.*`

#### `calculator_submission_processed` — Beregner behandlet

Starter når en beregner-indsendelse er færdigbehandlet. *(category: calculators)*

**Placeholders:** `calculator.*`, `lead.*`

#### `reply_intent_detected` — Svar-intention registreret

Starter når AI registrerer en intention i et svar. *(category: ai)*

**Placeholders:** `email.*`, `lead.*`, `person.*`, `ai.*`

#### `lifecycle_stage_changed` — Livscyklus-stage ændret

Starter når en kontakts livscyklus-stage ændres. *(category: ai)*

**Placeholders:** `person.*`, `company.*`, `lead.*`, `owner.*`

#### `engagement_risk_detected` — Engagement-risiko

Starter når der registreres risiko for lavt engagement. *(category: ai)*

**Placeholders:** `person.*`, `company.*`, `lead.*`, `owner.*`

#### `winback_opportunity` — Winback-mulighed

Starter når der opstår en winback-mulighed. *(category: ai)*

**Placeholders:** `person.*`, `company.*`, `lead.*`, `owner.*`

#### `support_ticket_created` — Supportsag oprettet

Starter når en supportsag oprettes. *(category: support)*

**Placeholders:** `ticket.*`, `person.*`, `company.*`

#### `support_ticket_updated` — Supportsag opdateret

Starter når en supportsag opdateres. *(category: support)*

**Placeholders:** `ticket.*`, `person.*`, `company.*`, `owner.*`

#### `support_ticket_resolved` — Supportsag løst

Starter når en supportsag løses. *(category: support)*

**Placeholders:** `ticket.*`, `person.*`, `company.*`, `owner.*`

#### `support_ticket_closed` — Supportsag lukket

Starter når en supportsag lukkes. *(category: support)*

**Placeholders:** `ticket.*`, `person.*`, `company.*`, `owner.*`

#### `support_ticket_reopened` — Supportsag genåbnet

Starter når en supportsag genåbnes. *(category: support)*

**Placeholders:** `ticket.*`, `person.*`, `company.*`, `owner.*`

#### `support_ticket_stage_changed` — Supportsag stage ændret

Starter når en supportsag skifter stage. *(category: support)*

**Placeholders:** `ticket.*`, `person.*`, `company.*`, `owner.*`

#### `support_ticket_assigned` — Supportsag tildelt

Starter når en supportsag tildeles en bruger. *(category: support)*

**Placeholders:** `ticket.*`, `person.*`, `company.*`, `owner.*`

#### `tag_added` — Tag tilføjet

Starter når et tag tilføjes til en entity. *(category: general)*

**Config:**

- `entityType` (enum): Hvilken entitetstype skal lyttes på
- `tagId` (string): Specifikt tag-id, eller 'any' for alle tags

**Placeholders:** `lead.*`, `person.*`, `company.*`, `owner.*`

#### `tag_removed` — Tag fjernet

Starter når et tag fjernes fra en entity. *(category: general)*

**Config:**

- `entityType` (enum): Entitetstype
- `tagId` (string): Tag-id eller 'any'

**Placeholders:** `lead.*`, `person.*`, `company.*`, `owner.*`

#### `user_joined_workspace` — Bruger tilføjet

Starter når en bruger tilføjes til workspacet. *(category: users)*

**Placeholders:** `owner.*`

#### `meta_lead_form_submitted` — Meta lead-formular

Starter når en Meta/Facebook lead-formular indsendes. Lead oprettes automatisk medmindre »Opret leads automatisk« er slået fra under Meta-indstillinger. *(category: integrations)*

**Placeholders:** `lead.*`, `meta.*`, `person.*`, `company.*`

#### `custom_object_created` — Brugerdefineret objekt oprettet

Starter når en record oprettes i et brugerdefineret objekt. *(category: custom_objects)*

**Config:**

- `customObjectId` (string): Begræns til specifikt objekt (valgfrit)

**Placeholders:** `custom_record.*`, `owner.*`

#### `custom_object_updated` — Brugerdefineret objekt opdateret

Starter når en record opdateres i et brugerdefineret objekt. *(category: custom_objects)*

**Config:**

- `customObjectId` (string): Begræns til specifikt objekt (valgfrit)

**Placeholders:** `custom_record.*`, `owner.*`

#### `record_created` — Record oprettet

Starter når en valgt record-type oprettes (lead, virksomhed, person, deal, tilbud, event, support, booking eller brugerdefineret objekt). *(category: deals)*

**Config:**

- `entityTypes` (enum): Hvilke record-typer der overvåges ved oprettelse

**Placeholders:** `lead.*`, `company.*`, `person.*`, `deal.*`, `offer.*`, `event.*`, `ticket.*`

> Preferred over legacy single-entity triggers (lead_created, deal_created, etc.) for new flows.

#### `record_changed` — Record ændret

Starter når en valgt record opdateres eller tags ændres. *(category: leads)*

**Config:**

- `entityTypes` (enum): Hvilke entiteter der overvåges
- `changeTypes` (enum): Hvilke ændringstyper der udløser
- `tagId` (string): Specifikt tag-id ved tag-ændringer, eller 'any'

**Placeholders:** `lead.*`, `deal.*`, `company.*`, `person.*`, `owner.*`

> Preferred over legacy single-entity triggers (lead_created, deal_created, etc.) for new flows.

#### `scheduled` — Tidsplan

Starter på en fast tidsplan (cron-lignende). *(category: scheduling)*

**Config:**

- `frequency` (enum): Hvor ofte den kører. interval = hvert 5., 10., 15. eller 30. minut
- `intervalMinutes` (number): Minutter mellem kørsler (5, 10, 15 eller 30). Kun ved frequency=interval
- `dayOfWeek` (number): Ugedag 0-6 (kun weekly)
- `dayOfMonth` (number): Dag i måneden 1-28 (kun monthly)
- `time` (string): Tidspunkt HH:mm (ved hourly bruges kun minuttet. Ved interval ignoreres feltet)

> No entity context by itself — pair with a lookup node (optionally changeDetection) to load records.

#### `webhook` — Webhook

Starter når en ekstern webhook kalder automationens unikke URL. *(category: scheduling)*

**Config:**

- `secret` (string): Valgfri hemmelighed til at validere kald
- `entityMapping` (object): Mapping af payload til entity: { entityType: Lead|People|Company|Deal|none, entityIdField }

**Placeholders:** `webhookData.*`

> Webhook URL: POST /api/webhooks/automation/{automationId}. Payload fields available as {{webhookData.fieldName}}.

### Action Types

Action nodes perform operations. Set data.actionType and data.config on the node.

#### `send_email` — Send email

Sender en email til en kontakt på entityen. *(group: communication)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `subject` | string | Yes | — | Emnelinje — kritisk indhold brugeren skal angive (medmindre template vælges) |
| `body` | string | Yes | — | Email-brødtekst (HTML) — kritisk indhold (medmindre template vælges) |
| `emailMode` | enum | No | — | Sæt til template når templateId bruges. Hvis kun templateId er sat (uden emailMode), bruges skabelonen alligevel. |
| `templateId` | string | No | — | Brug en eksisterende email-template i stedet for at skrive emne/body. Alone (uden emailMode) is enough to enter template mode. |
| `recipientField` | enum | No | primary | Hvilken email-adresse: primary=Primær (første tilgængelige), secondary=Sekundær, work/privat/cvr/ai/faktura/support=type-markeret email, person/company=via forbindelse, priority=Person→Virksomhed, any=første tilgængelige, all=alle (multi-send), custom=fast adresse i feltet 'to'. |
| `recipientType` | enum | No | auto | KUN relevant når entityen er et Lead: 'auto'=kontakt først, ellers firma; 'contact'=personRef; 'company'=companyRef. Bestemmer om mailen går til kontakten eller firmaet. |
| `to` | string | No | — | KUN når recipientField=custom: fast email eller {{placeholder}}. |
| `senderAccountId` | string | No | — | Afsenderkonto — auto-detekteres hvis tom; spørg ikke |

#### `send_sms` — Send SMS

Sender en SMS til et telefonnummer på entityen. *(group: communication)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `message` | string | Yes | — | SMS-tekst — kritisk indhold brugeren skal angive |
| `phoneField` | enum | No | primary | Hvilket telefonfelt der bruges |
| `fromNumber` | string | No | — | Afsendernummer — bruger workspace-default hvis tom |

#### `create_task` — Opret kalenderbegivenhed

IMPORTANT: Despite the key name create_task, this creates a calendar Event (møde/opkald/opgave i kalenderen) — not a timeline log. Use create_activity to log on the entity timeline without a calendar slot. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `title` | string | Yes | — | Begivenhedens titel (NOT taskTitle — that field is sequences-only) |
| `description` | string | No | — | Beskrivelse |
| `activityType` | enum | No | meeting | Type i kalenderen |
| `priority` | enum | No | medium | Prioritet |
| `dateScheduleType` | enum | No | relative | Hvordan dato/tid vælges |
| `durationMinutes` | number | No | 30 | Varighed i minutter |
| `assignTo` | string | No | owner | Hvem begivenheden tildeles: owner | creator | placeholder | konkret bruger-ID fra teamlisten |
| `assignToPlaceholder` | string | No | — | Kun når assignTo=placeholder: bruger-ID |
| `calendarAccountId` | string | No | — | Kalenderkonto — tom = auto (ansvarliges egen eller første tilgængelige). Listen viser brugernavn + kalender. |
| `conflictPolicy` | enum | No | find_next | Ved dobbeltbooking: flyt til ledig, undlad oprettelse, eller opret alligevel |

#### `create_recall` — Opret genopkald

Opretter et planlagt genopkald (recall). *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `title` | string | Yes | — | Recall-titel |
| `message` | string | No | — | Noter til opkaldet |
| `phoneField` | enum | No | primary | Telefonfelt |
| `delayDays` | number | No | 0 | Dage til genopkald |
| `assignTo` | string | No | — | Tildeles til — default ejer |

#### `create_note` — Opret note

Tilføjer en note på entityen. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `noteContent` | string | Yes | — | Notens indhold. Field name is noteContent (NOT content) — the executor reads config.noteContent. |

#### `create_activity` — Opret aktivitet

Logs an Activity on the entity timeline only — does NOT create a calendar Event. Use create_task for calendar meetings/calls/tasks. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `activityType` | string | No | — | Aktivitetstype (timeline) |
| `description` | string | Yes | — | Beskrivelse af aktiviteten |

#### `update_record` — Opdater felt

Opdaterer ét eller flere felter på entityen via fieldUpdates-arrayet. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `recordType` | enum | No | lead | Hvilken record-type der opdateres (resolver entityId fra trigger-kontekst). custom_object kræver customRecord i kontekst. |
| `fieldUpdates` | array | Yes | — | Array of { id?, fieldKey, value, isCustomField?, customFieldType? }. fieldKey is the field name (e.g. status, score) — NOT fieldPath. Legacy single field+value is still accepted by the executor but do not use it for new flows. |

#### `assign_user` — Tildel adgang (legacy)

Legacy — brug set_access i stedet. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `assignmentType` | enum | No | owner | — |
| `userId` | string | No | — | Bruger |
| `groupId` | string | No | — | Gruppe |

#### `set_access` — Tildel adgang

Sæt ejer, synlighed (gruppe/privat) og deling. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `recordType` | enum | No | trigger | — |
| `accessState` | object | Yes | — | Adgangskonfiguration (ejer, synlighed, delinger) |

#### `move_deal_stage` — Flyt deal stage

Flytter dealen til et nyt pipeline-stage. Efter Slå op (Find alle) bruges den fundne deal automatisk. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `flowRef` | string | No | — | Pipeline (flowRef) |
| `stageId` | string | No | — | Mål-stage |
| `dealId` | string | No | — | Valgfri deal-id eller {{placeholder}} fra Slå op. Tom = deal fra trigger/opslag. |

#### `create_lead` — Opret lead

Opretter et nyt lead. (Deprecated — use create_record.) *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `title` | string | Yes | — | Lead-titel/navn |
| `email` | string | No | — | Email |
| `phone` | string | No | — | Telefon |
| `status` | string | No | — | Status — default workspace-standard |

#### `create_deal` — Opret deal

Opretter en ny deal i en pipeline. (Deprecated — use create_record.) *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `title` | string | Yes | — | Deal-titel |
| `value` | number | No | — | Værdi |
| `flowRef` | string | No | — | Pipeline (flowRef) |
| `stageId` | string | No | — | Start-stage |

#### `create_contact` — Opret kontakt

Opretter en ny person/kontakt. (Deprecated — use create_record.) *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `firstName` | string | Yes | — | Fornavn |
| `lastName` | string | No | — | Efternavn |
| `email` | string | No | — | Email |

#### `add_to_sequence` — Tilmeld sequence

Tilmelder entityen til en sequence. There is no remove_from_sequence action — use sequence exit conditions or manual unenroll. When already enrolled: forceRestart=false skips (already_enrolled); forceRestart=true restarts. Sequence-level duplicateHandling is a separate enrollment-API setting. *(group: communication)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `sequenceId` | string | No | — | Hvilken sequence |
| `entityType` | enum | No | trigger | 'trigger' = use the triggering entity; otherwise override target type (ID inferred from context). |
| `forceRestart` | boolean | No | false | If true and an active/paused enrollment exists, restart it. If false (default), skip when already enrolled. Independent of sequence.duplicateHandling. |

#### `create_offer` — Opret tilbud

Opretter et tilbud. (Deprecated — use create_record.) *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `templateId` | string | No | — | Tilbudsskabelon |
| `title` | string | Yes | — | Tilbudstitel |

#### `http_request` — HTTP-kald

Sender et HTTP-request til et eksternt endpoint og gør svaret tilgængeligt som variabler. *(group: general)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `method` | enum | No | GET | HTTP-metode |
| `url` | string | Yes | — | Endpoint-URL — kritisk |
| `headers` | object | No | — | HTTP-headers som key/value |
| `body` | json | No | — | Request-body (JSON, kan indeholde placeholders) |
| `responseMapping` | object | No | — | Map felter fra svaret til navngivne variabler |

#### `send_webhook` — Send webhook

Sender en webhook (POST) til en URL. *(group: general)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `url` | string | Yes | — | Webhook-URL — kritisk |
| `payload` | json | No | — | Payload (JSON) |

#### `ordrestyring_create_debtor` — Opret debitor

Opretter en debitor i økonomisystemet. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Mapping af entity-felter til debitor-felter (navn, CVR, email) |

#### `ordrestyring_update_debtor` — Opdater debitor

Opdaterer en eksisterende debitor. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Felter der opdateres |

#### `ordrestyring_create_case` — Opret sag

Opretter en sag i økonomisystemet. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Sags-felter |

#### `ordrestyring_create_order_confirmation` — Opret ordrebekræftelse

Opretter en ordrebekræftelse i økonomisystemet. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Ordre-felter og linjer |

#### `create_record` — Opret record

Opretter lead, deal, kontakt, tilbud eller brugerdefineret objekt-record i ét samlet trin. *(group: crm)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `recordType` | enum | Yes | lead | Hvilken record-type der oprettes |
| `fieldValues` | object | No | — | Standardfelter for record-typen (fx title, email, value, stageId) |
| `customFields` | array | No | — | Brugerdefinerede felter [{ name, value, type }] |
| `customObjectId` | string | No | — | Påkrævet når recordType=custom_object — ID på objektdefinition |

#### `add_to_list` — Tilføj til liste

Tilføjer entityen til en statisk liste. *(group: lists)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `listId` | string | Yes | — | Mål-liste (statisk liste-ID) |

#### `remove_from_list` — Fjern fra liste

Fjerner entityen fra en statisk liste. *(group: lists)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `listId` | string | Yes | — | Liste entityen fjernes fra |

#### `billy_create_contact` — Billy: Opret kontakt

Opretter en kontakt i Billy. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Mapping af entity-felter til Billy-kontakt |

#### `apacta_create_contact` — Apacta: Opret kontakt

Opretter en kontakt i Apacta. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Mapping af entity-felter til Apacta-kontakt |

#### `economic_create_customer` — e-conomic: Opret kunde

Opretter en kunde i e-conomic. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Mapping til e-conomic kunde-felter (navn, CVR, email) |

#### `economic_create_invoice` — e-conomic: Opret faktura

Opretter en faktura i e-conomic. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Fakturalinjer og kunde-reference |

#### `economic_book_invoice` — e-conomic: Bogfør faktura

Bogfører en faktura i e-conomic. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `invoiceId` | string | No | — | Faktura-ID eller {{placeholder}} |

#### `uniconta_create_customer` — Uniconta: Opret kunde

Opretter en kunde i Uniconta. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Mapping til Uniconta kunde-felter |

#### `uniconta_create_invoice` — Uniconta: Opret faktura

Opretter en faktura i Uniconta. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `mapping` | object | No | — | Fakturalinjer og kunde-reference |

#### `uniconta_book_invoice` — Uniconta: Bogfør faktura

Bogfører en faktura i Uniconta. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `invoiceId` | string | No | — | Faktura-ID eller {{placeholder}} |

#### `dinero_create_contact` — Dinero: Opret kontakt

Opretter en kontakt i Dinero. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `contactName` | string | No | — | Kontaktnavn i feltet Navn (config.contactName). Alternativt config.mapping.name. |
| `contactCountryKey` | string | No | — | Valgfri landekode til Dinero CountryKey (ISO-2). Tomt bliver DK. |
| `mapping` | object | No | — | Valgfri mapping (name/email/cvr/countryKey) hvis contact*-felter mangler |

#### `dinero_create_invoice` — Dinero: Opret faktura

Opretter en kladdefaktura i Dinero. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `useOfferLines` | boolean | No | — | Sæt true for at kopiere linjer fra det udløsende tilbud (kræver en tilbuds-trigger). Uden linje-config falder runtime også tilbage til tilbuddets linjer. |
| `lines` | array | No | — | Statiske fakturalinjer: [{ description, quantity, unitPrice, accountNumber?, unit? }]. Placeholders tilladt i description. Alternativt config.mapping.lines/lineItems/invoiceLines. |
| `contactGuid` | string | No | — | Dinero kontakt-GUID. Tomt bliver {{dinero_contact_guid}} fra et tidligere Dinero: Opret kontakt-trin. Alternativt config.mapping.contactGuid. |
| `mapping` | object | No | — | Valgfri mapping-aliaser: mapping.lines/lineItems/invoiceLines (array eller '{{offer.lineItems}}'), mapping.contactGuid, mapping.useOfferLines |

#### `dinero_book_invoice` — Dinero: Bogfør faktura

Bogfører en faktura i Dinero. *(group: integrations)*

**Config:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `invoiceId` | string | No | — | Faktura-ID eller {{placeholder}} |

### Condition Node

Condition nodes evaluate rules and branch the flow. Edges use sourceHandle 'true' (met) or 'false' (not met).

**Data schema:**

- `label` (string): Display label
- `conditions` (array): Array of { id, field, operator, value, dataType? }
- `logic` (string): Combine multiple conditions

**Operators:**

| Operator | Label |
|----------|-------|
| `equals` | equals |
| `not_equals` | not equals |
| `contains` | contains |
| `not_contains` | not contains |
| `starts_with` | starts with |
| `ends_with` | ends with |
| `matches_regex` | matches regex |
| `greater_than` | greater than |
| `less_than` | less than |
| `greater_than_or_equal` | greater than or equal |
| `less_than_or_equal` | less than or equal |
| `between` | between |
| `is_empty` | is empty |
| `is_not_empty` | is not empty |
| `exists` | exists |
| `not_exists` | not exists |
| `is_true` | is true |
| `is_false` | is false |
| `array_contains` | array contains |
| `array_length_equals` | array length equals |
| `array_length_greater` | array length greater |
| `date_before` | date before |
| `date_after` | date after |
| `date_equals` | date equals |
| `date_within_days` | date within days |
| `date_in_exactly_days` | date in exactly days |

### Delay Node

Delay nodes pause execution for a specified duration before continuing.

- `label` (string): Display label
- `delayAmount` (number): Amount of time to wait
- `delayUnit` (string (minutes, hours, days, weeks)): Time unit (stored in data.config)

> Delays are rounded to 5-minute cron intervals.

### AI Node

AI nodes send execution context to an AI model. Settings live on node.data (not under config). Output available as {{ai_text}} and {{ai_json.*}}.

- `label` (string): Display label
- `prompt` (string): AI prompt with {{placeholders}}
- `outputMode` (string (text, json_schema)): undefined
- `reasoningEffort` (string (none, low, medium, high)): undefined
- `schema` (object): JSON Schema when outputMode is json_schema

**Output context:**

- `ai_text`: string
- `ai_json.*`: structured fields from schema or freeform JSON

### Lookup Node

Lookup nodes query records and branch via sourceHandle found/empty/error. Settings live on node.data (NOT under config). mode=first continues once with the record; mode=count only counts; mode=all FANS OUT — one child AutomationExecution per match (downstream runs per record). Fan-out caps are plan-based (no user-editable limit field).

- `label` (string): Display label; also slugified into an optional lookup.<alias> placeholder namespace
- `entityType` (string (Lead, Deal, Company, People, Offer, Activity, Task, Booking, Event, SupportTicket, CustomObjectRecord)): Entity type to query
- `customObjectId` (string): Required when entityType is CustomObjectRecord
- `mode` (string (first, count, all)): first=findOne; count=countDocuments; all=fan-out child executions per entity (plan-capped)
- `filters` (array): Filter rules [{ field, operator, value }] relative to trigger context
- `sortField` (string): Sort field for first/all
- `sortOrder` (number (-1, 1)): Mongo sort order (-1 desc, 1 asc). Named sortOrder, not sortDirection.
- `changeDetection` (boolean): Snapshot watched fields; unchanged records take the empty handle
- `watchedFields` (array): Field paths watched when changeDetection is true

**Output placeholders:** `lookup.<nodeId>.<field>`, `lookup.<nodeId>.count`, `lookup.<nodeId>.found`, `lookup.<alias>.<field>`, `lookup.<nodeId>.changed`, `lookup.<nodeId>.previous.*`

> Klik på opslag-noden → vælg entityType, mode og filtre. Forbind found/empty (og evt. error) handles. mode=all faner ud til N child-executions — efterfølgende noder kører pr. record. Placeholders: {{lookup.<nodeId>.field}} eller via label-alias.

### Flow Building Guide (AI Agents)

- **layout:** Automations flow top-down (vertical). Sequences flow left-to-right (horizontal).
- **nodeIds:** Use stable IDs: trigger_1, action_1, condition_1, delay_1, lookup_1, ai_1
- **branching:** Condition / AI condition: sourceHandle 'true'|'false'. Lookup: sourceHandle 'found'|'empty'|'error'.
- **storageConvention:** Automation action settings live under node.data.config. Logic nodes (condition, delay, ai, lookup) store settings on node.data (delay also accepts config.*). Sequences store ALL step settings on node.data — never under config.
- **placeholders:** Use {{entity.field}} syntax. AI output: {{ai_json.field}}, {{ai_text}}. Lookup: {{lookup.<nodeId>.field}} or {{lookup.<alias>.field}}.
- **compositeTriggers:** Prefer record_created and record_changed over legacy lead_created/deal_created/tag_added triggers.
- **createRecord:** Prefer create_record action over legacy create_lead/create_deal/create_contact/create_offer.
- **namingGotcha:** create_task = calendar Event. create_activity = timeline Activity only. create_note uses config.noteContent (not content). update_record uses config.fieldUpdates[{fieldKey,value}] + recordType (not fieldPath+value).
- **knownLimitations:** No remove_from_sequence (exit via sequence exit conditions).,No merge node; failureBehavior is global (no per-node catch edge).,No delete/archive entity action.,Lookup mode=all is fan-out to child executions — not an in-graph array iterator.,No business-hours gate on automation sends (use sequences for outreach timing).

### Execution Settings

- `failureBehavior` (string (stop, continue, retry), default: stop): 'stop' halts the flow, 'continue' skips the failed node, 'retry' retries
- `retryMaxAttempts` (number, default: 3): undefined
- `retryIntervalMinutes` (number, default: 5): undefined

### Rate Limits

- `maxRunsPerHour` (number, default: 0): 0 = unlimited
- `maxRunsPerDay` (number, default: 0): 0 = unlimited
- `maxRunsPerWeek` (number, default: 0): 0 = unlimited
- `maxTotalRuns` (number, default: 0): 0 = unlimited. Auto-deactivates after this many total runs.

### Placeholders

Placeholders use double curly braces: {{field.path}}. They are resolved from the execution context at runtime.

- **lead:** `lead._id`, `lead.title`, `lead.email`, `lead.phone`, `lead.status`, `lead.source`, `lead.score`, `lead.value`, `lead.notes`, `lead.customFields.*`
- **company:** `company._id`, `company.name`, `company.cvr`, `company.email`, `company.phone`, `company.address`, `company.city`, `company.zip`, `company.industry`, `company.employees`, `company.website`
- **contact:** `contact._id`, `contact.firstName`, `contact.lastName`, `contact.fullName`, `contact.email`, `contact.phone`, `contact.mobile`, `contact.title`
- **deal:** `deal._id`, `deal.title`, `deal.value`, `deal.stage`, `deal.probability`, `deal.priority`, `deal.customFields.*`
- **owner:** `owner._id`, `owner.firstName`, `owner.lastName`, `owner.fullName`, `owner.email`, `owner.phone`
- **call:** `call._id`, `call.duration`, `call.outcome`, `call.direction`, `call.notes`
- **email:** `email.subject`, `email.from`, `email.fromName`, `email.body`
- **sms:** `sms.body`, `sms.from`
- **webhook:** `webhookData.* (any field from the incoming webhook payload)`
- **lookup:** `lookup.<nodeId>.items`, `lookup.<nodeId>.count`, `lookup.<nodeId>.<field>`
- **system:** `triggeredAt`, `entityId`, `entityType`, `workspaceId`
- **previousSteps:** `httpResponse.status`, `httpResponse.data.*`, `ai_text`, `ai_json.*`, `aiOutput`, `aiOutput.*`

### Example Flows

#### New lead welcome email + follow-up task

When a lead is created, send a welcome email and create a follow-up task in 3 days

```json
{
  "name": "Welcome & Follow-up",
  "description": "Sends welcome email on lead creation and creates a 3-day follow-up task",
  "nodes": [
    {
      "id": "trigger_1",
      "type": "trigger",
      "position": {
        "x": 500,
        "y": 50
      },
      "data": {
        "triggerType": "lead_created",
        "label": "Lead created"
      }
    },
    {
      "id": "action_1",
      "type": "action",
      "position": {
        "x": 500,
        "y": 250
      },
      "data": {
        "actionType": "send_email",
        "label": "Send welcome email",
        "config": {
          "subject": "Welcome {{contact.firstName}}!",
          "body": "<p>Hi {{contact.firstName}},</p><p>Thank you for your interest. We will be in touch soon.</p><p>Best regards,<br>{{owner.firstName}}</p>"
        }
      }
    },
    {
      "id": "action_2",
      "type": "action",
      "position": {
        "x": 500,
        "y": 450
      },
      "data": {
        "actionType": "create_task",
        "label": "Follow-up task",
        "config": {
          "taskTitle": "Follow up with {{contact.firstName}} {{contact.lastName}}",
          "taskDescription": "New lead from {{lead.source}}. Score: {{lead.score}}",
          "dueInDays": 3,
          "assignTo": "owner"
        }
      }
    }
  ],
  "edges": [
    {
      "id": "e1",
      "source": "trigger_1",
      "target": "action_1"
    },
    {
      "id": "e2",
      "source": "action_1",
      "target": "action_2"
    }
  ]
}
```

#### Deal stage change with condition

When a deal moves to 'Proposal' stage, check if value > 50000 and send different emails

```json
{
  "name": "High-value deal notification",
  "description": "Notifies the owner differently based on deal value when deal reaches proposal stage",
  "nodes": [
    {
      "id": "trigger_1",
      "type": "trigger",
      "position": {
        "x": 500,
        "y": 50
      },
      "data": {
        "triggerType": "deal_stage_changed",
        "label": "Deal stage changed"
      }
    },
    {
      "id": "condition_1",
      "type": "condition",
      "position": {
        "x": 500,
        "y": 250
      },
      "data": {
        "label": "Deal value > 50000?",
        "conditions": [
          {
            "id": "c1",
            "field": "deal.value",
            "operator": "greater_than",
            "value": "50000"
          }
        ],
        "logic": "and"
      }
    },
    {
      "id": "action_1",
      "type": "action",
      "position": {
        "x": 300,
        "y": 450
      },
      "data": {
        "actionType": "send_email",
        "label": "High-value alert",
        "config": {
          "to": "{{owner.email}}",
          "subject": "High-value deal: {{deal.title}}",
          "body": "<p>Deal {{deal.title}} worth {{deal.value}} has moved to proposal stage!</p>"
        }
      }
    },
    {
      "id": "action_2",
      "type": "action",
      "position": {
        "x": 700,
        "y": 450
      },
      "data": {
        "actionType": "create_task",
        "label": "Standard follow-up",
        "config": {
          "taskTitle": "Follow up on {{deal.title}}",
          "dueInDays": 2,
          "assignTo": "owner"
        }
      }
    }
  ],
  "edges": [
    {
      "id": "e1",
      "source": "trigger_1",
      "target": "condition_1"
    },
    {
      "id": "e2",
      "source": "condition_1",
      "target": "action_1",
      "sourceHandle": "true"
    },
    {
      "id": "e3",
      "source": "condition_1",
      "target": "action_2",
      "sourceHandle": "false"
    }
  ]
}
```

#### Webhook-triggered lead creation

External system sends webhook data, automation creates a lead and notifies the team

```json
{
  "name": "Webhook lead import",
  "description": "Creates leads from incoming webhook data and sends a Slack/webhook notification",
  "nodes": [
    {
      "id": "trigger_1",
      "type": "trigger",
      "position": {
        "x": 500,
        "y": 50
      },
      "data": {
        "triggerType": "webhook",
        "label": "Webhook received"
      }
    },
    {
      "id": "action_1",
      "type": "action",
      "position": {
        "x": 500,
        "y": 250
      },
      "data": {
        "actionType": "create_lead",
        "label": "Create lead",
        "config": {
          "title": "{{webhookData.company_name}} — {{webhookData.contact_name}}",
          "email": "{{webhookData.email}}",
          "phone": "{{webhookData.phone}}",
          "source": "webhook"
        }
      }
    },
    {
      "id": "action_2",
      "type": "action",
      "position": {
        "x": 500,
        "y": 450
      },
      "data": {
        "actionType": "send_webhook",
        "label": "Notify team",
        "config": {
          "webhookUrl": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
          "webhookBody": "{\"text\": \"New lead from webhook: {{webhookData.company_name}}\"}"
        }
      }
    }
  ],
  "edges": [
    {
      "id": "e1",
      "source": "trigger_1",
      "target": "action_1"
    },
    {
      "id": "e2",
      "source": "action_1",
      "target": "action_2"
    }
  ]
}
```

#### AI-powered email response handler

When an email reply is received, use AI to analyze sentiment and route accordingly

```json
{
  "name": "AI Email Router",
  "description": "Analyzes email replies with AI and creates appropriate tasks based on sentiment",
  "nodes": [
    {
      "id": "trigger_1",
      "type": "trigger",
      "position": {
        "x": 500,
        "y": 50
      },
      "data": {
        "triggerType": "email_replied",
        "label": "Email replied"
      }
    },
    {
      "id": "ai_1",
      "type": "ai",
      "position": {
        "x": 500,
        "y": 250
      },
      "data": {
        "label": "Analyze reply",
        "prompt": "Analyze this email reply and determine the sentiment and intent.\n\nFrom: {{email.fromName}}\nSubject: {{email.subject}}\nBody: {{email.body}}\n\nReturn JSON with sentiment (positive/negative/neutral) and intent (interested/not_interested/question/meeting_request).",
        "outputMode": "json_schema",
        "schema": {
          "type": "object",
          "properties": {
            "sentiment": {
              "type": "string",
              "enum": [
                "positive",
                "negative",
                "neutral"
              ]
            },
            "intent": {
              "type": "string",
              "enum": [
                "interested",
                "not_interested",
                "question",
                "meeting_request"
              ]
            },
            "summary": {
              "type": "string"
            }
          },
          "required": [
            "sentiment",
            "intent",
            "summary"
          ]
        }
      }
    },
    {
      "id": "condition_1",
      "type": "condition",
      "position": {
        "x": 500,
        "y": 450
      },
      "data": {
        "label": "Positive sentiment?",
        "conditions": [
          {
            "id": "c1",
            "field": "aiOutput.sentiment",
            "operator": "equals",
            "value": "positive"
          }
        ],
        "logic": "and"
      }
    },
    {
      "id": "action_1",
      "type": "action",
      "position": {
        "x": 300,
        "y": 650
      },
      "data": {
        "actionType": "create_task",
        "label": "Priority follow-up",
        "config": {
          "taskTitle": "Priority: {{contact.firstName}} replied positively — {{aiOutput.summary}}",
          "dueInDays": 0,
          "assignTo": "owner"
        }
      }
    },
    {
      "id": "action_2",
      "type": "action",
      "position": {
        "x": 700,
        "y": 650
      },
      "data": {
        "actionType": "create_task",
        "label": "Review reply",
        "config": {
          "taskTitle": "Review reply from {{contact.firstName}}: {{aiOutput.intent}}",
          "dueInDays": 1,
          "assignTo": "owner"
        }
      }
    }
  ],
  "edges": [
    {
      "id": "e1",
      "source": "trigger_1",
      "target": "ai_1"
    },
    {
      "id": "e2",
      "source": "ai_1",
      "target": "condition_1"
    },
    {
      "id": "e3",
      "source": "condition_1",
      "target": "action_1",
      "sourceHandle": "true"
    },
    {
      "id": "e4",
      "source": "condition_1",
      "target": "action_2",
      "sourceHandle": "false"
    }
  ]
}
```

### Webhook Trigger URL

`POST /api/webhooks/automation/{automationId}`

Any JSON payload — all fields become available as webhookData.* placeholders

> The automation must be active and have a webhook trigger type configured.

---

## Sequences

Sequences are entity-centric outreach flows for personalized sales communication. Unlike automations (which are event-triggered), sequences are started by enrolling an entity (Lead, People, Deal, or Company). Each sequence has a Start node, communication nodes (email, SMS), timing nodes (wait), action nodes (tasks, recalls, field updates), logic nodes (conditions, AI), and Stop nodes.

### Sequences vs Automations

| Aspect | Sequences | Automations |
|--------|-----------|-------------|
| start | Enrollment (manual or automatic) | Trigger (event-based) |
| purpose | Outreach campaigns & nurturing | React to CRM events |
| focus | Email/SMS communication sequences | Broad automation of any action |
| exit | Exit conditions (reply, meeting booked, etc.) | Runs to completion |
| timing | Smart scheduling with business hours | Event-driven, immediate execution |

### Entity Types

- **Lead:** Leads in the system — cold outreach, nurturing
- **People:** Contact persons — personal outreach, follow-up
- **Company:** Companies — account-based outreach
- **Deal:** Deals in pipeline — deal acceleration, engagement

### Sequence Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/sequences` | List all sequences in the workspace |
| `POST` | `/api/v1/sequences` | Create a new sequence |
| `GET` | `/api/v1/sequences/:id` | Get a single sequence by ID |
| `PUT` | `/api/v1/sequences/:id` | Update a sequence |
| `DELETE` | `/api/v1/sequences/:id` | Delete a sequence. Fails if there are active enrollments. |
| `POST` | `/api/v1/sequences/:id/toggle` | Toggle sequence active/inactive. Activation requires a start node and at least one action node. |
| `POST` | `/api/v1/sequences/:id/duplicate` | Duplicate a sequence (creates inactive copy) |
| `GET` | `/api/v1/sequences/:id/enroll-eligibility` | Check if an entity can be enrolled before calling enroll |
| `POST` | `/api/v1/sequences/:id/enroll` | Enroll one or more entities into the sequence |
| `GET` | `/api/v1/sequences/:id/enrollments` | List enrollments for a sequence |
| `GET` | `/api/v1/sequences/:id/stats` | Get aggregate statistics for a sequence |
| `POST` | `/api/v1/sequences/enrollments/:enrollmentId/pause` | Pause an active enrollment |
| `POST` | `/api/v1/sequences/enrollments/:enrollmentId/resume` | Resume a paused enrollment |
| `POST` | `/api/v1/sequences/enrollments/:enrollmentId/unenroll` | Remove an entity from the sequence (only if active or paused) |

### Step Types

Each sequence node has a type on node.type. Settings live on node.data (not nested under config). Flows are horizontal (left to right).

#### `start` — Start

Indgangspunktet for sequencen. Præcis én pr. flow.

#### `send_email` — Send email

Sender en email-step i sequencen.

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `subject` | string | Yes | — | Emnelinje — kritisk (medmindre template) |
| `body` | string | Yes | — | Email-brødtekst (HTML) — kritisk (medmindre template). Brug rich text med toolbar: vedhæft fil, indsæt billede, variabel, AI Composer. |
| `aiBlocks` | array | No | — | Inline AI-blokke [{ id, prompt }] — indsættes som {{ai:id}} i body og genereres pr. modtager ved afsendelse. Prompt kan indeholde variabler. |
| `attachments` | array | No | — | Vedhæftede filer [{ id, name, s3Key, workspaceFileId }] |
| `templateId` | string | No | — | Brug template i stedet for emne/body |
| `recipientType` | enum (contact, company, auto) | No | contact | Trin 1 'Hvem sender du til?': 'contact'=Person, 'company'=Virksomhed, 'auto'=prøv kontaktperson først, ellers virksomhed. KUN relevant når sequencen accepterer Lead/Deal eller en blanding (Company+People) — Company-only låses til virksomhed, People-only til person. Bekræft valget med brugeren for Lead/Deal/blandede sequences. |
| `companyRecipientTarget` | enum (contact_person, company_direct) | No | contact_person | KUN ved virksomheds-flow (recipientType company/auto eller Company-entity). 'contact_person'=send til en person tilknyttet virksomheden, 'company_direct'=send til virksomhedens egen email (fx info@, salg@). |
| `companyContactSelection` | object | No | — | KUN når companyRecipientTarget=contact_person. Objekt: { mode: 'first'|'beslutningstagere'|'all'|'byTitle'|'byTag', titleFilter, tagFilter, fallback: 'company_email'|'skip' }. mode vælger hvilke(n) kontaktperson(er) (byTitle kræver titleFilter, byTag kræver tagFilter); fallback bestemmer hvad der sker hvis ingen kontaktperson matcher. |
| `recipientField` | enum (primary, work, cvr, privat, faktura, support, ai, all, custom) | No | primary | Hvilken email-adresse på den valgte modtager: primary=Første email (uanset type), work=Arbejde, cvr=CVR, privat=Privat, faktura=Faktura, support=Support, ai=AI-fundet, all=send separat til ALLE emails, custom=fast adresse/placeholder i customRecipient. |
| `customRecipient` | string | No | — | KUN når recipientField=custom: fast email eller {{placeholder}} (fx {{contact.email}} eller salg@firma.dk). |
| `senderType` | enum (fixed, owner) | No | — | 'fixed' = bestemt konto, 'owner' = entity-ejers konto. Default = sequencens afsender |
| `senderAccountId` | string | No | — | Afsenderkonto — sæt via setEmailSender eller emailAccountId i updateNodeConfig (auto-resolver email-adresse) |
| `emailMode` | enum (manual, template) | No | manual | 'manual' = emne+body, 'template' = brug skabelon |
| `ignoreBusinessHours` | boolean | No | false | Send uden for åbningstid hvis true |
| `sendCondition` | object | No | — | Valgfrit: spring email over hvis felt matcher { enabled, field, operator: equals|not_equals, value } |
| `unsubscribeLinkEnabled` | boolean | No | false | Tilføj afmeldingslink i footer |
| `unsubscribeLinkPreset` | string | No | attio_default | Footer-preset for afmelding |

#### `send_sms` — Send SMS

Sender en SMS-step i sequencen.

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `message` | string | Yes | — | SMS-tekst — kritisk. Kan indeholde {{ai:id}} AI-blokke. |
| `aiBlocks` | array | No | — | Inline AI-blokke [{ id, prompt }] for SMS — genereres pr. modtager |
| `recipientType` | enum (contact, company, auto) | No | contact | Trin 1 'Hvem sender du til?': 'contact'=Person, 'company'=Virksomhed, 'auto'=prøv kontaktperson først, ellers virksomhed. KUN relevant for Lead/Deal/blandede sequences (Company-only=virksomhed, People-only=person). Bekræft valget med brugeren ved Lead/Deal/blandet. |
| `companyRecipientTarget` | enum (contact_person, company_direct) | No | contact_person | KUN ved virksomheds-flow. 'contact_person'=brug telefon fra en person tilknyttet virksomheden, 'company_direct'=brug virksomhedens eget telefonnummer. |
| `companyContactSelection` | object | No | — | KUN når companyRecipientTarget=contact_person. { mode: 'first'|'beslutningstagere'|'all'|'byTitle'|'byTag', titleFilter, tagFilter, fallback: 'company_phone'|'skip' }. |
| `phoneField` | enum (primary, mobile, work, privat, cvr, direkte, all, custom) | No | primary | Hvilket telefonnummer: primary=Første telefon, mobile=Mobil, work=Arbejde, privat=Privat, cvr=CVR, direkte=Direkte, all=alle numre, custom=fast nummer/placeholder i customPhone. |
| `customPhone` | string | No | — | KUN når phoneField=custom: fast nummer eller {{placeholder}} (fx {{contact.phone}} eller +4512345678). |
| `smsMode` | enum (manual, template) | No | manual | 'manual' = besked, 'template' = SMS-skabelon |
| `templateId` | string | No | — | SMS-skabelon (template mode) |
| `ignoreBusinessHours` | boolean | No | false | Send uden for åbningstid hvis true |
| `sendCondition` | object | No | — | Valgfrit: spring SMS over hvis felt matcher { enabled, field, operator, value } |
| `senderType` | enum (fixed, owner, alphanumeric) | No | fixed | Afsender-type for SMS |
| `fromNumber` | string | No | — | Telefonnummer (fast afsender) |
| `senderId` | string | No | — | Alfanumerisk afsender-ID (max 11 tegn) |

#### `wait` — Vent

Venter et tidsrum før næste step. Canonical fields: delayAmount + delayUnit on node.data (NOT under config). Aliases accepted by the executor: waitAmount/waitUnit, duration/unit, amount, and waitDays/waitHours.

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `delayAmount` | number | No | 1 | Antal tidsenheder (canonical) |
| `delayUnit` | enum (minutes, hours, days, weeks) | No | days | Tidsenhed (canonical) |

#### `create_task` — Opret kalenderbegivenhed

Creates a calendar Event for the enrolled entity (same calendar model as automation create_task). Field names differ from automations: use taskTitle / taskDescription on node.data (NOT config, NOT title — though title is accepted as a legacy alias).

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `taskTitle` | string | Yes | — | Event title. Canonical key is taskTitle (legacy alias: title). |
| `taskDescription` | string | No | — | Beskrivelse (canonical; not description) |
| `activityType` | enum (task, call, meeting, follow_up, email, deadline, reminder) | No | task | Kalender-type |
| `priority` | enum (low, medium, high) | No | medium | Prioritet |
| `dueInDays` | number | No | 1 | Dage til deadline. Default/runtime: 1. Note: stored 0 currently coerces to 1 (historical || 1) — use 1+ explicitly; do not rely on 0 for 'today'. |
| `assignTo` | enum (owner, creator) | No | owner | Hvem begivenheden tildeles |

#### `create_recall` — Opret genopkald

Planlægger et genopkald som en step.

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `title` | string | Yes | — | Recall-titel |
| `message` | string | No | — | Noter |
| `phoneField` | enum (primary, mobile, work, custom) | No | primary | Telefonfelt |
| `recipientType` | enum (contact, company, auto) | No | auto | Modtagertype |
| `delayDays` | number | No | 0 | Dage til genopkald |
| `callTime` | string | No | — | Tidspunkt HH:mm |
| `assignTo` | string | No | — | Tildeles til |

#### `update_field` — Opdater felt

Opdaterer ét felt på den enrolled entity. Uses fieldPath + value on node.data (sequences do NOT use automation's fieldUpdates array).

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `fieldPath` | string | Yes | — | Felt der opdateres (fx status, customFields.score) |
| `value` | string | Yes | — | Ny værdi |

#### `condition` — Betingelse

Forgrener sequencen så den udfører FORSKELLIGE handlinger (fx ud fra lead-score eller åben deal). For blot at stoppe ved svar/booking, brug exit conditions i stedet — ikke denne node.

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `field` | string | Yes | — | Felt der tjekkes (fx lead.score, deal.stage) |
| `operator` | enum (equals, not_equals, contains, not_contains, starts_with, ends_with, matches_regex, greater_than, less_than, greater_than_or_equal, less_than_or_equal, between, is_empty, is_not_empty, exists, not_exists, is_true, is_false, array_contains, array_length_equals, array_length_greater, date_before, date_after, date_equals, date_within_days, date_in_exactly_days) | No | is_true | Operator |
| `value` | string | No | — | Sammenligningsværdi (hvis relevant) |

**Branching:** [object Object]

#### `ai_analyzer` — AI-analyse

Analyserer kontekst med AI (fx et email-svar) og producerer output til efterfølgende betingelser.

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `prompt` | string | Yes | — | Analyse-prompt — kritisk |
| `outputMode` | enum (text, json_schema) | No | json_schema | Output-format |
| `reasoningEffort` | enum (none, low, medium, high) | No | low | Reasoning effort |
| `schema` | json | No | — | Schema for struktureret output → {{ai_json.<felt>}} |

#### `ai_condition` — AI-betingelse

AI træffer en ja/nej-beslutning og forgrener sequencen. (Manglede tidligere helt i flow-schemas.)

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `prompt` | string | Yes | — | Kriterie AI skal vurdere — kritisk |
| `confidenceThreshold` | number | No | 0.7 | Sikkerhedstærskel (0-1) |
| `useKnowledgeBase` | boolean | No | false | Brug vidensbase |

**Branching:** [object Object]

#### `set_access` — Tildel adgang

Sætter ejer, synlighed og deling på den enrolled entity.

**Data schema:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `recordType` | enum (trigger) | No | trigger | — |
| `accessState` | object | Yes | — | Adgangskonfiguration |

#### `stop` — Stop

Afslutter sequencen for entityen.

### Sequence Settings (document-level)

- **exitConditions:** onEmailReply, onSmsReply, onMeetingBooked, onDealStageChange.{enabled,stages[]}, onLeadStatusChange.{enabled,statuses[]}
- **sendSettings:** businessHoursOnly, businessStart, businessEnd, timezone, skipWeekends, smsQuietHours*, emailIntervalSeconds. Defaults if omitted: businessHoursOnly=false, skipWeekends=false (emails can send on weekends). Recommended DK defaults: businessHoursOnly=true, businessStart=08:00, businessEnd=16:30, timezone=Europe/Copenhagen, skipWeekends=true.
- **autoEnrollTriggers:** field_change | sequence_event | entity_created — see triggers[] on sequence document
- **duplicateHandling:** block | block_with_restart (default) | allow_multiple. Independent of automation add_to_sequence.forceRestart: when forceRestart=false and an enrollment is active, the automation skips; sequence duplicateHandling applies to enrollment API / auto-enroll paths.
- **storageConvention:** ALL sequence step settings live on node.data (never nested under config). Automations put action settings under node.data.config — opposite convention.
- **waitFields:** Canonical: delayAmount + delayUnit. Aliases: waitAmount/waitUnit, duration/unit, waitDays/waitHours.
- **createTaskFields:** Canonical: taskTitle, taskDescription, dueInDays, assignTo, priority, activityType. Legacy alias for title: title.
- **placeholders:** Namespaced only: {{contact.firstName}} — bare {{firstName}} does not resolve.

### Exit Conditions

Exit conditions automatically remove an entity from the sequence when certain events occur.

- `onEmailReply` (boolean, default: true): Exit when the entity replies to a sequence email
- `onSmsReply` (boolean, default: true): Exit when the entity replies to a sequence SMS
- `onMeetingBooked` (boolean, default: true): Exit when a meeting/booking is created for the entity
- `onDealStageChange`:
  - `enabled` (boolean): undefined
  - `stages` (number[]): Array of stage IDs that trigger exit
- `onLeadStatusChange`:
  - `enabled` (boolean): undefined
  - `statuses` (string[]): Array of status values that trigger exit (e.g. ['won', 'lost'])

**Exit reasons:** `email_reply`, `sms_reply`, `meeting_booked`, `deal_stage`, `lead_status`, `manual`, `ai_decision`

### Send Settings

Control when messages are sent in the sequence

- `businessHoursOnly` (boolean, default: false): Only send during business hours
- `businessStart` (string, default: 08:00): Business hours start (HH:mm)
- `businessEnd` (string, default: 16:30): Business hours end (HH:mm)
- `timezone` (string, default: Europe/Copenhagen): Timezone for scheduling
- `skipWeekends` (boolean, default: false): Skip Saturday and Sunday
- `smsQuietHoursEnabled` (boolean, default: false): Enforce SMS quiet hours
- `smsQuietHoursStart` (string, default: 20:00): SMS quiet hours start
- `smsQuietHoursEnd` (string, default: 08:00): SMS quiet hours end

### Tracking Settings

- `trackOpens` (boolean, default: true): Track email opens via pixel
- `trackClicks` (boolean, default: false): Track link clicks

### Duplicate Handling

Controls what happens when an entity is already enrolled

- **`block`:** Reject the enrollment if the entity is already active in this sequence
- **`block_with_restart`:** Unenroll from the current run and start fresh (default)
- **`allow_multiple`:** Allow multiple concurrent enrollments of the same entity

### Auto-Enrollment Triggers

Sequences can automatically enroll entities when specific conditions are met. Configure via the 'triggers' array on the sequence.

#### `field_change`

Enroll when a field on an entity changes to a specific value

- `entityType` (string): 
- `field` (string): Field path to watch
- `operator` (string): 
- `value` (string): 

#### `entity_created`

Enroll when a new entity is created matching filters

- `entityType` (string): 
- `filters` (object): Field-value filters the new entity must match

#### `sequence_event`

Enroll when an entity completes or exits another sequence

- `sourceSequenceId` (string): The other sequence ID
- `event` (string): Which event to trigger on

### Placeholders

Placeholders use double curly braces: {{namespace.field}}. Available in subject, body, message, titles, and descriptions. IMPORTANT: bare {{firstName}} does NOT resolve — use {{contact.firstName}} (or company.*/lead.*/deal.*/owner.*). This differs from some email-template shorthand; sequence context is namespaced.

- **contact:** `contact.firstName`, `contact.lastName`, `contact.fullName`, `contact.email`, `contact.phone`, `contact.mobile`, `contact.title`
- **company:** `company.name`, `company.cvr`, `company.email`, `company.phone`, `company.address`, `company.city`, `company.zip`, `company.industry`, `company.employees`, `company.website`
- **lead:** `lead._id`, `lead.title`, `lead.email`, `lead.phone`, `lead.status`, `lead.source`, `lead.score`, `lead.value`
- **deal:** `deal._id`, `deal.title`, `deal.value`, `deal.stage`, `deal.probability`, `deal.priority`
- **owner:** `owner.firstName`, `owner.lastName`, `owner.fullName`, `owner.email`, `owner.phone`
- **workspace:** `workspace.name`
- **system:** `currentDate`, `currentTime`, `enrolledAt`
- **customFields:** `customFields.field_key (any custom field defined on the entity)`
- **aiOutput:** `aiOutput (full AI response)`, `aiOutput.fieldName (structured field from AI json_schema output)`
- **stepHistory:** `stepHistory.lastEmail.replied`, `stepHistory.lastEmail.opened`, `stepHistory.lastSms.replied`

**Formatting syntax:**

- `{{contact.firstName}} — basic placeholder (required namespace)`
- `{{contact.firstName|default:Customer}} — fallback value if empty`
- `{{deal.value|format:currency}} — format as currency`
- `{{currentDate|format:date}} — format as date`

### Example Flows

#### 3-email cold outreach

Send 3 emails spaced 3 days apart with a stop at the end

```json
{
  "name": "Cold Outreach 3-Touch",
  "description": "3-email cold outreach sequence with 3-day intervals",
  "nodes": [
    {
      "id": "start_1",
      "type": "start",
      "position": {
        "x": 50,
        "y": 300
      },
      "data": {
        "label": "Start"
      }
    },
    {
      "id": "email_1",
      "type": "send_email",
      "position": {
        "x": 330,
        "y": 300
      },
      "data": {
        "label": "First email",
        "subject": "Hi {{contact.firstName}} — quick question",
        "body": "<p>Hi {{contact.firstName}},</p><p>I noticed {{company.name}} might benefit from our solution. Would you be open to a quick chat this week?</p><p>Best,<br>{{owner.firstName}}</p>"
      }
    },
    {
      "id": "wait_1",
      "type": "wait",
      "position": {
        "x": 690,
        "y": 300
      },
      "data": {
        "label": "Wait 3 days",
        "delayAmount": 3,
        "delayUnit": "days"
      }
    },
    {
      "id": "email_2",
      "type": "send_email",
      "position": {
        "x": 990,
        "y": 300
      },
      "data": {
        "label": "Follow-up",
        "subject": "Re: Hi {{contact.firstName}} — quick question",
        "body": "<p>Hi {{contact.firstName}},</p><p>Just following up on my last email. I'd love to show you how we've helped similar companies in {{company.industry}}.</p><p>Best,<br>{{owner.firstName}}</p>"
      }
    },
    {
      "id": "wait_2",
      "type": "wait",
      "position": {
        "x": 1350,
        "y": 300
      },
      "data": {
        "label": "Wait 3 days",
        "delayAmount": 3,
        "delayUnit": "days"
      }
    },
    {
      "id": "email_3",
      "type": "send_email",
      "position": {
        "x": 1650,
        "y": 300
      },
      "data": {
        "label": "Final attempt",
        "subject": "Last try — {{contact.firstName}}",
        "body": "<p>Hi {{contact.firstName}},</p><p>I don't want to be a bother. If now isn't the right time, no worries at all. Just let me know if you'd like to connect in the future.</p><p>Best,<br>{{owner.firstName}}</p>"
      }
    },
    {
      "id": "stop_1",
      "type": "stop",
      "position": {
        "x": 2010,
        "y": 300
      },
      "data": {
        "label": "Stop"
      }
    }
  ],
  "edges": [
    {
      "id": "e1",
      "source": "start_1",
      "target": "email_1"
    },
    {
      "id": "e2",
      "source": "email_1",
      "target": "wait_1"
    },
    {
      "id": "e3",
      "source": "wait_1",
      "target": "email_2"
    },
    {
      "id": "e4",
      "source": "email_2",
      "target": "wait_2"
    },
    {
      "id": "e5",
      "source": "wait_2",
      "target": "email_3"
    },
    {
      "id": "e6",
      "source": "email_3",
      "target": "stop_1"
    }
  ],
  "exitConditions": {
    "onEmailReply": true,
    "onMeetingBooked": true
  },
  "sendSettings": {
    "businessHoursOnly": true,
    "skipWeekends": true,
    "timezone": "Europe/Copenhagen"
  }
}
```

#### Multi-channel with condition

Email then SMS with a condition check for email open

```json
{
  "name": "Multi-channel Follow-up",
  "description": "Sends email, waits 2 days, checks if opened, then sends SMS or second email",
  "nodes": [
    {
      "id": "start_1",
      "type": "start",
      "position": {
        "x": 50,
        "y": 300
      },
      "data": {
        "label": "Start"
      }
    },
    {
      "id": "email_1",
      "type": "send_email",
      "position": {
        "x": 330,
        "y": 300
      },
      "data": {
        "label": "Initial email",
        "subject": "{{contact.firstName}}, quick intro",
        "body": "<p>Hi {{contact.firstName}},</p><p>I wanted to introduce myself and our solution for {{company.name}}.</p><p>{{owner.firstName}}</p>"
      }
    },
    {
      "id": "wait_1",
      "type": "wait",
      "position": {
        "x": 690,
        "y": 300
      },
      "data": {
        "label": "Wait 2 days",
        "delayAmount": 2,
        "delayUnit": "days"
      }
    },
    {
      "id": "condition_1",
      "type": "condition",
      "position": {
        "x": 990,
        "y": 300
      },
      "data": {
        "label": "Email opened?",
        "conditions": [
          {
            "field": "stepHistory.lastEmail.opened",
            "operator": "is_true"
          }
        ],
        "logic": "and"
      }
    },
    {
      "id": "sms_1",
      "type": "send_sms",
      "position": {
        "x": 1350,
        "y": 150
      },
      "data": {
        "label": "SMS follow-up",
        "message": "Hi {{contact.firstName}}, I sent you an email about {{company.name}}. Would love to chat briefly — {{owner.firstName}}",
        "phoneField": "primary"
      }
    },
    {
      "id": "email_2",
      "type": "send_email",
      "position": {
        "x": 1350,
        "y": 450
      },
      "data": {
        "label": "Re-engage email",
        "subject": "Did you see my email, {{contact.firstName}}?",
        "body": "<p>Hi {{contact.firstName}},</p><p>I sent an email a couple of days ago but wanted to make sure it didn't get lost. Happy to chat whenever works for you.</p><p>{{owner.firstName}}</p>"
      }
    },
    {
      "id": "task_1",
      "type": "create_task",
      "position": {
        "x": 1710,
        "y": 300
      },
      "data": {
        "label": "Manual follow-up",
        "taskTitle": "Call {{contact.firstName}} at {{company.name}}",
        "taskDescription": "Sequence complete — time for a personal touch",
        "dueInDays": 1,
        "assignTo": "owner"
      }
    },
    {
      "id": "stop_1",
      "type": "stop",
      "position": {
        "x": 2070,
        "y": 300
      },
      "data": {
        "label": "Stop"
      }
    }
  ],
  "edges": [
    {
      "id": "e1",
      "source": "start_1",
      "target": "email_1"
    },
    {
      "id": "e2",
      "source": "email_1",
      "target": "wait_1"
    },
    {
      "id": "e3",
      "source": "wait_1",
      "target": "condition_1"
    },
    {
      "id": "e4",
      "source": "condition_1",
      "target": "sms_1",
      "sourceHandle": "true"
    },
    {
      "id": "e5",
      "source": "condition_1",
      "target": "email_2",
      "sourceHandle": "false"
    },
    {
      "id": "e6",
      "source": "sms_1",
      "target": "task_1"
    },
    {
      "id": "e7",
      "source": "email_2",
      "target": "task_1"
    },
    {
      "id": "e8",
      "source": "task_1",
      "target": "stop_1"
    }
  ],
  "exitConditions": {
    "onEmailReply": true,
    "onSmsReply": true,
    "onMeetingBooked": true
  },
  "sendSettings": {
    "businessHoursOnly": true,
    "skipWeekends": true
  }
}
```

#### AI-powered reply analysis

Send email, wait for reply, use AI to analyze and route

```json
{
  "name": "AI Reply Analyzer",
  "description": "Sends outreach email, waits for reply, uses AI to determine next action",
  "nodes": [
    {
      "id": "start_1",
      "type": "start",
      "position": {
        "x": 50,
        "y": 300
      },
      "data": {
        "label": "Start"
      }
    },
    {
      "id": "email_1",
      "type": "send_email",
      "position": {
        "x": 330,
        "y": 300
      },
      "data": {
        "label": "Outreach email",
        "subject": "{{contact.firstName}} — idea for {{company.name}}",
        "body": "<p>Hi {{contact.firstName}},</p><p>I have an idea that could help {{company.name}} save time. Worth a 15-min call?</p><p>{{owner.firstName}}</p>"
      }
    },
    {
      "id": "wait_1",
      "type": "wait",
      "position": {
        "x": 690,
        "y": 300
      },
      "data": {
        "label": "Wait 5 days",
        "delayAmount": 5,
        "delayUnit": "days"
      }
    },
    {
      "id": "ai_1",
      "type": "ai_condition",
      "position": {
        "x": 990,
        "y": 300
      },
      "data": {
        "label": "Got a reply?",
        "prompt": "Has the contact {{contact.firstName}} at {{company.name}} replied to our last email? Check the step history for any email reply.",
        "reasoningEffort": "low"
      }
    },
    {
      "id": "ai_2",
      "type": "ai_analyzer",
      "position": {
        "x": 1350,
        "y": 150
      },
      "data": {
        "label": "Analyze reply",
        "prompt": "Analyze the reply from {{contact.firstName}}. Determine if they are interested, need more info, or are declining. Reply history: {{stepHistory.lastEmail.replyBody}}",
        "outputMode": "json_schema",
        "schema": {
          "type": "object",
          "properties": {
            "intent": {
              "type": "string",
              "enum": [
                "interested",
                "needs_info",
                "declining",
                "out_of_office"
              ]
            },
            "suggestedAction": {
              "type": "string"
            }
          },
          "required": [
            "intent",
            "suggestedAction"
          ]
        }
      }
    },
    {
      "id": "task_1",
      "type": "create_task",
      "position": {
        "x": 1750,
        "y": 150
      },
      "data": {
        "label": "Act on reply",
        "taskTitle": "{{contact.firstName}} replied: {{aiOutput.intent}} — {{aiOutput.suggestedAction}}",
        "dueInDays": 0,
        "assignTo": "owner"
      }
    },
    {
      "id": "email_2",
      "type": "send_email",
      "position": {
        "x": 1350,
        "y": 450
      },
      "data": {
        "label": "Bump email",
        "subject": "Re: {{contact.firstName}} — idea for {{company.name}}",
        "body": "<p>Hi {{contact.firstName}},</p><p>Just bumping this to the top of your inbox. Let me know if you'd like to chat.</p><p>{{owner.firstName}}</p>"
      }
    },
    {
      "id": "stop_1",
      "type": "stop",
      "position": {
        "x": 2110,
        "y": 300
      },
      "data": {
        "label": "Stop"
      }
    }
  ],
  "edges": [
    {
      "id": "e1",
      "source": "start_1",
      "target": "email_1"
    },
    {
      "id": "e2",
      "source": "email_1",
      "target": "wait_1"
    },
    {
      "id": "e3",
      "source": "wait_1",
      "target": "ai_1"
    },
    {
      "id": "e4",
      "source": "ai_1",
      "target": "ai_2",
      "sourceHandle": "true"
    },
    {
      "id": "e5",
      "source": "ai_1",
      "target": "email_2",
      "sourceHandle": "false"
    },
    {
      "id": "e6",
      "source": "ai_2",
      "target": "task_1"
    },
    {
      "id": "e7",
      "source": "task_1",
      "target": "stop_1"
    },
    {
      "id": "e8",
      "source": "email_2",
      "target": "stop_1"
    }
  ],
  "exitConditions": {
    "onEmailReply": false,
    "onMeetingBooked": true
  }
}
```

### Building a Sequence — Step by Step

1. **Create the sequence** — POST /api/v1/sequences with name and the full flow (nodes + edges)
   > Include all nodes and edges in the initial creation, or create empty and update later.
2. **Configure settings** — PUT /api/v1/sequences/:id with exitConditions, sendSettings, allowedEntityTypes
   > Set exit conditions (onEmailReply, onMeetingBooked) and send settings (business hours, timezone).
3. **Activate the sequence** — POST /api/v1/sequences/:id/toggle to activate
   > The sequence must have a start node and at least one action node.
4. **Enroll entities** — POST /api/v1/sequences/:id/enroll with entityType and entityId or entityIds
   > Entities begin at the start node and progress through the flow automatically.
5. **Monitor progress** — GET /api/v1/sequences/:id/enrollments and /api/v1/sequences/:id/stats
   > Check enrollment statuses and aggregate statistics.

---

## Webhooks

Subscribe to CRM events and receive HTTP POST notifications at your URL. Each webhook delivery includes an HMAC signature for verification. Manage subscriptions via the /api/v1/webhooks/subscriptions endpoints.

### Available Events

| Event | Description |
|-------|-------------|
| `company.created` | A company was created |
| `company.updated` | A company was updated |
| `company.deleted` | A company was deleted |
| `person.created` | A person was created |
| `person.updated` | A person was updated |
| `person.deleted` | A person was deleted |
| `deal.created` | A deal was created |
| `deal.updated` | A deal was updated |
| `deal.deleted` | A deal was deleted |
| `deal.stage_changed` | A deal moved to a different pipeline stage |
| `deal.won` | A deal was marked as won |
| `deal.lost` | A deal was marked as lost |
| `lead.created` | A lead was created |
| `lead.updated` | A lead was updated |
| `lead.deleted` | A lead was deleted |
| `call.created` | A call record was created |
| `call.updated` | A call record was updated |
| `note.created` | A note was created |
| `note.updated` | A note was updated |
| `task.created` | A task was created |
| `task.updated` | A task was updated |
| `task.completed` | A task was marked as completed |
| `activity.created` | An activity was logged |
| `custom_object.created` | A custom object record was created |
| `custom_object.updated` | A custom object record was updated |
| `custom_object.deleted` | A custom object record was deleted |
| `offer.created` | An offer was created |
| `offer.updated` | An offer was updated |
| `offer.deleted` | An offer was deleted |
| `offer.sent` | An offer was sent to recipient |
| `contract.sent` | A contract was sent for signature |
| `contract.signed` | A signer signed a contract |
| `contract.completed` | All signers signed a contract |
| `contract.declined` | A signer declined a contract |
| `contract.voided` | A contract was voided by the sender |
| `contract.expired` | A contract signing deadline expired |
| `booking.created` | A booking was created |
| `booking.approved` | A booking was approved |
| `booking.rejected` | A booking was rejected |
| `booking.cancelled` | A booking was cancelled |
| `booking.rescheduled` | A booking was rescheduled |
| `support_ticket.created` | A support ticket was created |
| `support_ticket.updated` | A support ticket was updated |
| `support_ticket.stage_changed` | A support ticket moved kanban stage |
| `support_ticket.deleted` | A support ticket was deleted |

### Delivery Format

Each webhook delivery sends a POST request to your URL with this JSON body:

**Body schema:**

- `event` (string): Event name (e.g. 'lead.created')
- `timestamp` (string): ISO 8601 timestamp
- `data` (object): The entity data that triggered the event
- `workspaceId` (string): undefined

**Headers:**

- `Content-Type`: application/json
- `X-Webhook-Signature`: HMAC-SHA256 signature of the request body using your webhook secret

### Signature Verification

Verify webhook authenticity by computing HMAC-SHA256 of the raw request body with your secret

```javascript
const crypto = require('crypto');

function verifyWebhook(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(body))
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
```

### Subscription Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/webhooks/subscriptions` | List subscriptions |
| `POST` | `/api/v1/webhooks/subscriptions` | Create subscription (returns secret) |
| `GET` | `/api/v1/webhooks/subscriptions/:id` | Get subscription |
| `PUT` | `/api/v1/webhooks/subscriptions/:id` | Update subscription |
| `DELETE` | `/api/v1/webhooks/subscriptions/:id` | Delete subscription |

### Inbound Webhooks

Salesbase also accepts inbound webhooks to trigger automations

- **`POST /api/webhooks/automation/:automationId`** — Trigger a webhook-type automation. The JSON body becomes available as webhookData.* placeholders in the automation flow.
  Authentication: Header x-webhook-secret must match the secret on the automation webhook trigger node.
- **`POST /api/v1/webhooks/zapier`** — Zapier inbound — creates person, lead, and deal. Bearer API key with inbound scope.
  Authentication: undefined
