# 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) |

### Companies

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/companies` | List companies |
| `POST` | `/api/v1/companies` | Create a company |
| `GET` | `/api/v1/companies/:id` | Get company by ID |
| `PUT` | `/api/v1/companies/:id` | Update company |
| `DELETE` | `/api/v1/companies/:id` | Delete company |
| `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 | — | — |
| `search` | 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 | — |

### People (contacts)

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/people` | List people |
| `POST` | `/api/v1/people` | Create a person |
| `GET` | `/api/v1/people/:id` | Get person by ID |
| `PUT` | `/api/v1/people/:id` | Update person |
| `DELETE` | `/api/v1/people/:id` | Delete person |
| `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 |

#### `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 | — |

### Leads

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/leads` | List leads |
| `POST` | `/api/v1/leads` | Create a lead |
| `GET` | `/api/v1/leads/:id` | Get lead by ID |
| `PUT` | `/api/v1/leads/:id` | Update lead |
| `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 | — | — |

#### `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 | — |
| `value` | number | No | — |
| `notes` | string | No | — |
| `companyRef` | string | No | Company ID |
| `personRef` | string | No | Person ID |
| `customFields` | object | No | — |
| `tags` | string[] | No | — |

### 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) |
| `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 | — |

#### `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. status enum includes to_do (not open). |
| `POST` | `/api/v1/tasks` | Create task |
| `GET` | `/api/v1/tasks/:id` | Get task |
| `PUT` | `/api/v1/tasks/:id` | Update task |
| `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, done, cancelled) |
| `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 | — | — |

### 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

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/custom-fields` | List custom field definitions |
| `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/custom-fields`

**Query Parameters:**

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

### 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 | — |

### 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

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/emails` | List emails |
| `POST` | `/api/v1/emails` | Send email |
| `GET` | `/api/v1/emails/:id` | Get email |
| `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 |

### Email Accounts

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

### Email Templates

Create and manage email templates with custom HTML (compiledHtml). 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 compiledHtml, 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 | — |
| `compiledHtml` | string | No | Full HTML body with {{placeholders}} |
| `plainText` | string | No | — |
| `metadata` | object | No | { subject, preheader } |
| `layoutSettings` | object | No | containerWidth, backgroundColor, layoutMode |
| `tags` | string[] | No | — |

### SMS

Requires API key scope `sms` (not covered by `write` alone).

| 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 SMS. Requires scope: sms. |
| `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` | Send bulk SMS. Requires scope: sms. |
| `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 | — |
| `message` | string | Yes | — |
| `from` | string | No | Sender number |

### 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 |

### Offers (quotes/proposals)

Create draft offers linked to offer types and templates. 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 |

### Offer Templates (tilbudsskabeloner)

Visual page-builder templates with pages[], headerElements, footerElements, branding, and custom HTML elements. See ?section=offerTemplates for element types and A-Polering reference.

| 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 elements (text, heading, html, columns, line_items, totals, video, …) |
| `headerElements` | array | No | — |
| `footerElements` | array | No | — |
| `branding` | object | No | colors, typography, logo |
| `defaultTerms` | string | No | — |
| `defaultPaymentTerms` | string | No | — |
| `isDefault` | boolean | No | — |

### Offer Types

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/offer-types` | List offer types |
| `POST` | `/api/v1/offer-types` | Create offer type (admin scope) |
| `GET` | `/api/v1/offer-types/:id` | Get offer type |
| `PATCH` | `/api/v1/offer-types/:id` | Update offer type (admin scope) |
| `DELETE` | `/api/v1/offer-types/:id` | Delete offer type (admin scope) |

### 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 |
| `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 |
| `DELETE` | `/api/v1/projects/tasks/:taskId` | Delete project task |
| `POST` | `/api/v1/projects/tasks/:taskId/move` | Move project task |
| `GET` | `/api/v1/projects/:id/time-entries` | List time entries |
| `POST` | `/api/v1/projects/:id/time-entries` | Create time entry |
| `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 |

### 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 |
| `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) |

#### `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

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

### 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 — not GET). Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/deals` | Export deals (POST only — not GET). Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/people` | Export people (POST only — not GET). Requires scope: export. Admin/owner role required. |
| `POST` | `/api/v1/export/companies` | Export companies (POST only — not GET). 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` | Create signed download link |
| `DELETE` | `/api/v1/files/:id/link` | Revoke signed download link |
| `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.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/support-tickets` | List support tickets |
| `POST` | `/api/v1/support-tickets` | Create support ticket |
| `GET` | `/api/v1/support-tickets/:id` | Get support ticket by ID |
| `PUT` | `/api/v1/support-tickets/:id` | Update support ticket |
| `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 | — | — |
| `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 | — |
| `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 | — |
| `assignee` | string | No | — |
| `tags` | string[] | No | — |
| `resolution` | string | No | — |

#### `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) |
| `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) |
| `DELETE` | `/api/v1/custom-objects/:slug` | Delete object and all records (admin scope) |
| `POST` | `/api/v1/custom-objects/:slug/fields` | Add field (admin scope) |
| `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 |
| `POST` | `/api/v1/custom-objects/:slug/records/bulk` | Bulk update records |
| `GET` | `/api/v1/custom-objects/:slug/records/:id` | Get record by ID |
| `PATCH` | `/api/v1/custom-objects/:slug/records/:id` | Partial update of record |
| `PUT` | `/api/v1/custom-objects/:slug/records/:id` | Replace record |
| `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 | — |

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

**Query Parameters:**

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

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

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `slug` | string | Yes | — |
| `label` | string | Yes | — |
| `type` | string | Yes | — |

#### `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 | — |

#### `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 | — |

---

## 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 | — | — |

### 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 | — | — |
| `isRequired` | boolean | — | — |
| `isUnique` | boolean | — | — |
| `options` | array | — | For select/multiselect |
| `referenceObject` | string | — | Custom object slug for record_reference |
| `referenceType` | string | — | — |
| `nativeEntity` | string | — | company, person, deal, lead when referenceType=native |
| `order` | number | — | — |

### CustomObjectRecord

A record instance for a custom object. Field values live under data keyed by field slug.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `object` | ObjectId | — | CustomObjectDefinition _id |
| `recordNumber` | number | — | Auto-increment per object type |
| `data` | object | — | Map of fieldSlug → value |
| `workspace` | ObjectId | — | — |
| `createdBy` | ObjectId | — | — |
| `updatedBy` | ObjectId | — | — |
| `createdAt` | Date | — | — |
| `updatedAt` | Date | — | — |

### OfferTemplate

Tilbudsskabelon with page builder layout. Mix native elements (text, line_items) and custom html blocks.

| 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 | — | — |
| `workspaceId` | ObjectId | — | — |

### EmailTemplate

Email template with compiledHtml and optional GrapesJS projectData.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `_id` | ObjectId | — | — |
| `name` | string | Yes | — |
| `category` | string | — | — |
| `compiledHtml` | string | — | HTML with {{contact.firstName}} placeholders |
| `plainText` | string | — | — |
| `metadata` | object | — | { subject, preheader } |
| `layoutSettings` | object | — | — |
| `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 | — | — |
| `pricingRules` | object | — | tieredPricing, conditionalRules, packages, globalSurcharges |
| `offerSettings` | object | — | OTP, deal/sequence on accept/submit |
| `customPlaceholders` | array | — | → {{custom.fieldId}} in templates |
| `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.*`

#### `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, deal, tilbud, event, support ticket, booking). *(category: deals)*

**Config:**

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

**Placeholders:** `lead.*`, `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) |
| `templateId` | string | No | — | Brug en eksisterende email-template i stedet for at skrive emne/body |
| `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 |
| `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
