<!--
  Served statically from this public folder: https://app.masterinbox.com/public-workflow-api.md

  KEEP IN SYNC. The behaviour described here is enforced by mi-api, in a different repo:
    libs/common/src/services/workflow-validator.service.ts   (triggers, actions, id types)
    libs/common/src/constants/workflow-filter-fields.ts      (filter fields and conditions)
  Change either of those and this file must be updated in the same PR, or agents will build
  payloads the API rejects.
-->

# MasterInbox Workflow API

Reference for building MasterInbox workflows over the public API. Written to be read by an agent:
every allowed value is stated inline, and every refusal the API can return is listed with what to do
about it.

**Base URL:** `https://api.masterinbox.com/api/api-webhook/v1/api`
**Auth:** `Authorization: Bearer <api_key>` on every request.
**Workspace:** defaults to the key's workspace. Pass `workspace_id` to target another one.

A workflow is **one trigger** (the event), **optional conditions** (which prospects it applies to),
and **1–10 actions** (what happens). It runs per prospect.

---

## Before you build: fetch the catalog

```http
GET /workflows/filter-fields
```

Returns the live `trigger_types`, `action_types`, and every filter field with its allowed conditions
and value shape. It is generated from the same catalog the validator enforces, so it cannot disagree
with what the API accepts. **Prefer it over the tables below** — those are a snapshot, this is truth.

## Rejections tell you exactly what to fix

Invalid payloads return `400` with **every** problem at once, each naming the field. Fix them all and
resend; do not retry unchanged.

```json
{
  "status": false,
  "message": "Workflow validation failed",
  "errors": [
    "action_configs[0].email_subject: required for action \"email_notify\"",
    "conditions.a7f3k2p9.condition: \"contains\" is not allowed for field \"labels\" — allowed: equals, not_equals, OR, NOT, is_empty, not_empty"
  ]
}
```

A message ending **"configure this action in the app"** means the feature exists but cannot be set up
over the API. Do not retry it or substitute a different action — tell the user to do that part in the
MasterInbox web app.

---

## Responses

Every endpoint answers with the same envelope. **Branch on `status`, not on the HTTP code.**

`create-workflow` and `update-workflow` return the saved workflow in `data` — read
`data.workflow_id` from there for follow-up calls.

```json
{
  "status": "success",
  "message": "Workflow created successfully",
  "data": {
    "workflow_id": 4127,
    "workspace_id": 219,
    "name": "Notify Slack when a hot lead replies",
    "is_active": 1,
    "trigger_type": "incoming_message",
    "trigger_config": { "channel_ids": ["-1"] },
    "action_configs": [],
    "created_at": "2026-08-19T09:14:22.000Z"
  }
}
```

**A workflow that does not exist returns `200`, not `404`** — with `status: "error"` and the reason
in `message`. Checking the HTTP code alone will read a miss as a hit:

```json
{ "status": "error", "message": "Workflow not found", "data": [] }
```

| Situation                          | HTTP          | Body                                                     |
| ---------------------------------- | ------------- | -------------------------------------------------------- |
| Success                            | `200` / `201` | `status: "success"`                                      |
| Workflow missing, or a save failed | `200`         | `status: "error"`, reason in `message`                   |
| Validation failed                  | `400`         | `status: "error"` plus `errors[]`, one entry per problem |
| Missing or invalid API key         | `401`         | `Credentials not found` / `Invalid API credentials`      |
| Too many requests                  | `429`         | limit is 100 requests per 60 seconds                     |

Only `400` carries the `errors[]` array. Everything else puts a single reason in `message`.

---

## Endpoints

| Method  | Path                                | Purpose                                        |
| ------- | ----------------------------------- | ---------------------------------------------- |
| `GET`   | `/workflows/filter-fields`          | Catalog of triggers, actions and filter fields |
| `GET`   | `/workflows`                        | List workflows                                 |
| `GET`   | `/workflows/{workflow_id}`          | Fetch one                                      |
| `POST`  | `/workflows/create-workflow`        | Create                                         |
| `PATCH` | `/workflows/update-workflow`        | Replace trigger, conditions and actions        |
| `PATCH` | `/workflows/change-workflow-status` | `is_active`: `1` run, `0` pause                |
| `POST`  | `/workflows/delete-workflow`        | Delete permanently (prefer pausing)            |

### Create

```json
POST /workflows/create-workflow
{
  "name": "Notify Slack when a hot lead replies",
  "description": "optional",
  "trigger_type": "incoming_message",
  "trigger_config": { "channel_ids": ["-1"] },
  "action_configs": [
    { "action_type": "add_labels", "label_ids": [5] },
    { "action_type": "slack_notify", "slack_webhook_url": "https://hooks.slack.com/services/…" }
  ]
}
```

`update-workflow` takes the same body plus `workflow_id`, and **replaces** the whole configuration —
send the complete trigger, conditions and actions, not just what changed.

---

## Triggers

`trigger_config` requirements differ per trigger. `[-1]` means "any".

| `trigger_type`                                                                                       | Required in `trigger_config`      |
| ---------------------------------------------------------------------------------------------------- | --------------------------------- |
| `add_label`, `remove_label`                                                                          | `label_ids`: number[]             |
| `moved_to_list`, `remove_from_list`                                                                  | `inbox_type_ids`: number[]        |
| `incoming_message`, `send_message`, `seen_message`                                                   | `channel_ids`: string[]           |
| `due_reminder`, `new_reminder`                                                                       | —                                 |
| `found_phone_number`                                                                                 | —                                 |
| `meeting_booked`, `meeting_rescheduled`, `meeting_cancelled`, `meeting_completed`, `meeting_no_show` | —                                 |
| `draft_held`                                                                                         | optional `hold_reasons`: string[] |

**`draft_held`** fires once per reply the AI reply agent held instead of sending (any hold kind:
reply could not be verified, not safe to send, unclear if a reply is needed, no reply needed,
calendar action failed/rejected, tool unavailable, awaiting calendar approval, and the post-approval
outcomes: approval expired, approved action failed, action done but reply failed). `trigger_config.hold_reasons`
narrows it to specific reason codes (`reviewer_block`, `unsalvageable_grounding`, `unsafe_draft`,
`reply_need_unresolved`, `reply_not_needed`, `calendar_action_failed`, `calendar_action_rejected`,
`calendar_approval_pending`, `calendar_approval_failed`, `calendar_approval_reply_failed`, `calendar_approval_expired`, `tool_unavailable`, `duplicate_follow_up_content`, `internal_link_leak`,
`reviewer_unavailable_unverified_action`, `fallback_guard_failed`, `blocked_quality`); omit or `[]` for every hold.
Slack/webhook actions may include the extra payload variables `hold_reason`, `hold_reason_code` and
`agent_name` alongside the usual prospect fields (`thread_url`, `name`, `email`, …). Conditions are supported.
The Slack draft card (Accept/Edit) is not posted for this trigger — a held reply has no sendable draft.

**`send_message` only** also accepts a delay before the actions run:
`trigger_delay_value` (whole number) + `trigger_delay_unit` (`"minutes"` | `"hours"`, max 30 days).

**Refused triggers** — do not attempt:

|                         | Why                                                                                      |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| `incoming_webhook`      | Multi-step setup (generate URL → send a sample payload → map its keys). App only.        |
| `scheduled_view_report` | Not exposed. App only.                                                                   |
| `move_to_folder`        | Not a trigger — no event produces it. You probably want the **action** of the same name. |

**Prerequisites that are not payload errors.** The meeting triggers need a Cal.com or Calendly
integration connected in the app; `found_phone_number` needs enrichment providers configured. The API
accepts these workflows either way — they simply never fire until the integration exists.

---

## Conditions (optional)

Conditions narrow which prospects the workflow acts on. Omit for "all prospects".

> ⚠️ **Only these 8 triggers accept conditions:** `add_label`, `remove_label`, `moved_to_list`,
> `remove_from_list`, `incoming_message`, `send_message`, `seen_message`, `due_reminder`.
>
> Sending `conditions` with any other trigger — the five `meeting_*` types, `new_reminder`,
> `found_phone_number` — is **rejected**, not ignored. Those triggers have no conditions builder in
> the app, so a workflow created with them via the API would silently lose its conditions the first
> time someone opened and saved it there.
>
> `GET /workflows/filter-fields` returns this list as `triggers_supporting_conditions`.

`trigger_config.conditions` is an **object keyed by an arbitrary unique string** (any short random id
you choose), each value one rule:

```json
"conditions": {
  "a7f3k2p9": { "field": "labels", "operator": "OR", "condition": "equals", "values": ["176479", "176478"] },
  "b2k9m4x1": { "field": "domain", "operator": "AND", "condition": "not_contains", "values": ["ourcompany.com"] }
}
```

| Key         | Meaning                                                                             |
| ----------- | ----------------------------------------------------------------------------------- |
| `field`     | What to filter on — see the table below                                             |
| `condition` | The comparison — must be allowed **for that field**                                 |
| `operator`  | How this rule joins the others: `AND` \| `OR` \| `NOT`                              |
| `values`    | Always an array of **strings**. Omit only for `is_empty` / `not_empty` / `not_set`. |
| `status`    | Optional. `0` disables the rule without deleting it.                                |

> ⚠️ **Id types are not uniform — get these wrong and the workflow saves but never fires.**
>
> | Where            | Field                                         | Type                                                              |
> | ---------------- | --------------------------------------------- | ----------------------------------------------------------------- |
> | `trigger_config` | `label_ids`, `inbox_type_ids`                 | **number** — `[176479]`                                           |
> | `trigger_config` | `channel_ids`                                 | **string** — the channel's `_id`, e.g. `["am9obkBhY21lLmNvbQ=="]` |
> | `conditions`     | `values` (all fields)                         | **string** — `["176479"]`                                         |
> | `action_configs` | `label_ids`, `inbox_type_id`, `template_id`   | **number** — `[7158]`                                             |
> | `action_configs` | `campaign_ids`, `list_ids`, `subsequence_ids` | **string** — provider ids, e.g. `["c-9f3a"]`                      |
>
> The `"-1"` / `-1` all-sentinel is accepted in either form.

> ⚠️ **A channel id is the `_id` field from `POST /get-channels` — not its `channel_id` field.**
> Each channel returned there carries both; only `_id` works. It is never a plain number: mailboxes
> use the base64 of the address (`am9obkBhY21lLmNvbQ==`), LinkedIn uses the account id, HeyReach uses
> `heyreach_<id>`. Every channel id you send — in `channel_ids`, in a `channel_id` condition, or on an
> action — is checked against this workspace's channels, and an unknown one is rejected. Without that
> check it would save fine and then never fire.
>
> These are not stylistic. The worker matches label and list events with numeric comparison and
> channel events with string comparison; the app resolves action ids with `===`. A mismatched type
> is never an error at run time — the comparison simply never matches. The API rejects the wrong
> type up front and names the corrected form.

> ⚠️ **Conditions restrict to prospects who have already replied.** Any workflow carrying conditions
> implicitly matches only replied prospects. This is not configurable. If you need a workflow to act
> on prospects who have never replied, use no conditions.

### Filter fields

| `field`                | Allowed `condition` values                                                                            | `values` shape                                    |
| ---------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `last_message`         | between, equals, not_equals, less_than, less_than_equal, greater_than, greater_than_equal             | date string(s); 2 for `between`                   |
| `reply_since`          | less_than, greater_than, equals, not_equals, between                                                  | tuple — see below                                 |
| `reminder_at`          | due_in, past_due_by, not_set                                                                          | number                                            |
| `name`, `email`        | equals, not_equals, contains, not_contains, starts_with, ends_with, AND, OR, NOT, is_empty, not_empty | string[]                                          |
| `domain`               | equals, not_equals, starts_with, ends_with, contains, not_contains, AND, OR, NOT, is_empty, not_empty | string[]                                          |
| `campaign_name`        | equals, not_equals, contains, not_contains, AND, OR, NOT, is_empty, not_empty                         | string[] — **campaign names, not ids**            |
| `subjects`             | contains, not_contains, AND, OR, NOT, is_empty, not_empty                                             | string[]                                          |
| `messages`             | contains, not_contains, AND, OR, NOT                                                                  | string[] (keywords)                               |
| `channel_id`           | equals, not_equals, OR, NOT                                                                           | string[] of channel `_id`s — `POST /get-channels` |
| `labels`               | equals, not_equals, OR, NOT, is_empty, not_empty                                                      | string[] of ids — `GET /get-labels`               |
| `inbox_type_id`        | equals, not_equals, OR, NOT                                                                           | string[] of ids — `GET /get-inbox-types`          |
| `label_sentiment`      | equals                                                                                                | `positive` \| `negative` \| `neutral`             |
| `seen`                 | equals                                                                                                | `"1"` read \| `"0"` unread                        |
| `has_smartlead`        | equals                                                                                                | `"1"` \| `"0"`                                    |
| `sequencer`            | **seq_smartlead** \| **seq_lemlist**                                                                  | `"1"` \| `"0"`                                    |
| `has_draft`            | equals                                                                                                | `"1"` \| `"0"`                                    |
| `source`               | equals, not_equals, OR, NOT                                                                           | string[] (integration slugs)                      |
| `folder`               | equals, not_equals, OR, NOT                                                                           | `archive` \| `marketing` \| `spam` \| `trash`     |
| `ongoing_conversation` | equals                                                                                                | `yes` \| `no` \| `only_sent`                      |
| `last_msg_from`        | equals                                                                                                | `me` \| `prospect`                                |
| `sent_from`            | equals                                                                                                | `MI` \| `MI:manual` \| `MI:reply-agent`           |
| `custom_fields`        | equals, OR, is_empty, not_empty                                                                       | string[] — **field names, not ids**               |
| `count_by`             | equals, less_than, greater_than, less_than_equal, greater_than_equal, between                         | tuple — see below                                 |

**Id sentinels** (`channel_id`, `labels` only): `"-1"` alone = all. `"-1"` plus `"exclude:<id>"`
entries = all except those.

**The two positional tuples** — order matters, these are not objects:

- `reply_since` → `[metric, mode, amount, unit]`, or for `between`: `[metric, "between", min, max, unit]`.
  `metric` ∈ `last_message` (any reply) | `last_sent_at` (your reply) | `last_received_at` (their reply).
  Example: `["last_received_at", "custom", "48", "hour"]`
- `count_by` → `[metric, value]`, or for `between`: `[metric, min, max]`.
  `metric` ∈ `reply_count` | `sent_count` | `follow_up_count`.
  Example: `["reply_count", "3"]`

---

## Actions

1–10 per workflow, executed in order. Every action needs `action_type`; the rest depends on it.

### Inbox

| `action_type`                                  | Required                                                                                                                 |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `add_labels`, `remove_labels`, `update_labels` | `label_ids`: number[] (`update_labels` replaces all existing labels; no duplicates)                                      |
| `move_to_list`                                 | `inbox_type_id`: number                                                                                                  |
| `remove_from_list`                             | —                                                                                                                        |
| `move_to_folder`                               | `folder_id`: `archive` \| `marketing` \| `spam` \| `trash`                                                               |
| `mark_as_unread`                               | —                                                                                                                        |
| `set_reminder`                                 | `reminder_minutes`: number (e.g. 60 = 1h, 1440 = 1 day)                                                                  |
| `enrich_prospect`                              | `enrich_with`: `smartlead` \| `instantly` \| `emailbison` \| `plusvibe` \| `contact_enrichment_bundle` \| `enrich_phone` |

### Notifications

| `action_type`   | Required                                                       | Optional                                                                                                                                       |
| --------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhook`       | `webhook_url`                                                  | `webhook_payload_variables`: string[]                                                                                                          |
| `slack_notify`  | `slack_webhook_url`                                            | `payload_variables`: string[]; `action_buttons`; `show_draft` (default `true`)                                                                 |
| `email_notify`  | `email_to`: string[], `email_subject`, `email_body` (**HTML**) | `email_cc`, `email_bcc`, `use_mi_smtp` (default `true`), `channel_id`, `show_logo` (default `true`), `show_view_conversation` (default `true`) |
| `forward_email` | `forward_email_addresses`: string[]                            | `source_from`, `channel_id`, `forward_last_message_only`                                                                                       |

- `email_notify` with `use_mi_smtp: false` **requires** `channel_id` — a channel `_id` from `POST /get-channels`.
- `forward_email` with `source_from: "channel"` **requires** `channel_id` — a channel `_id` from `POST /get-channels`.
- `webhook_payload_variables` / `payload_variables` choose which fields the webhook or Slack message
  carries. Allowed names: `job_id`, `triggered_by`, `workflow_id`, `date`, `workspace_id`, `prospect_id`,
  `workspace_name`, `text`, `channel`, `thread_url`, `seen`, `name`, `first_name`, `last_name`, `email`,
  `to_name`, `to_address`, `from_name`, `from_address`, `cc_emails`, `message_quantity`,
  `conversation_stage`, `subject`, `linkedin`, `phone`, `list_name`, `channel_id`, `deal_value`,
  `job_title`, `label_names`, `custom_fields`, `reply_from`, `source`, `company_name`, `campaign_id`,
  `campaign_name`, `campaign_source`, `campaign_step`, `location` — plus `meeting_time`, `meeting_url`
  on meeting triggers. **An unknown name is not rejected; it is sent as `null`.** Note these differ from
  the email variables below: here it is `company_name` and `label_names`.
- `slack_notify` `action_buttons` work **only on the `incoming_message` trigger**, max 5, each
  `{ id, label, template_id }` with optional `style` (`primary` | `danger`). They also require the
  MasterInbox Slack app's own webhook URL — a plain Slack incoming webhook silently drops the buttons.
- `slack_notify` `show_draft` (default `true`, `incoming_message` trigger) posts the notification as a
  card with the prospect's message, the reply currently drafted for them and Accept / Edit buttons —
  a human-approval feature. **Refused:** an active workflow with `show_draft` on whose scope overlaps an
  active **Fully Automated** reply agent (create, update and activation all return `status: "error"`
  naming the agent). Overlap is judged on `channel_ids`, the `labels` condition AND the `campaign_name`
  condition together — all three must overlap. A `campaign_name` `equals` / `not_equals` on exact names,
  or a `contains` / `OR` / `AND` match that cannot hit any of the agent's campaign names, clears it;
  `not_empty`, `is_empty`, `NOT` and unknown conditions count as "all campaigns". A name-based "disjoint"
  is also verified against existing prospects (workflow query ∩ agent campaigns), so a renamed campaign
  still counts as an overlap while prospects carry the old name. Narrow `channel_ids`,
  the `labels` condition or the `campaign_name` condition, set `show_draft: false`, or switch that agent
  to human-in-the-loop. The same rule refuses creating or activating a fully-automated agent on top of
  such a workflow (`create-reply-agent` / `update-reply-agent`).
- **72-hour rule:** `webhook`, `slack_notify` and `email_notify` are skipped when the thread's last
  message is older than 72 hours.

#### Writing `email_subject` and `email_body`

**The body is HTML.** It is inserted into the email as-is; line breaks in plain text are not
converted, so a multi-line plain-text body arrives as one run-on line. Wrap each line in `<p>…</p>`.
The API rejects a multi-line body with no HTML tags.

**Variables** are written `{{variable ?? fallback}}`. The fallback is used when the prospect has no
value, and is sent **exactly as written** — so write `{{first_name ?? there}}`, never
`{{first_name ?? "there"}}` (the quotes would reach the recipient; the API rejects this). Always give
a real fallback: the app's own variable picker inserts the word `fallback`, which is what a prospect
with no name would then read. `{{variable}}` with no fallback renders as empty when there is no value.
A fallback cannot contain `}`.

| Variable         | Value                                                                                                  |
| ---------------- | ------------------------------------------------------------------------------------------------------ |
| `name`           | Full name. **Has no email-based fallback** — prefer `first_name`                                       |
| `first_name`     | First name; falls back to the first word of the name, then the email's local part — almost never empty |
| `last_name`      | Last name                                                                                              |
| `email`, `phone` | Prospect's email / phone                                                                               |
| `domain`         | Company domain, or the domain of the prospect's email                                                  |
| `company`        | Company name                                                                                           |
| `linkedin_url`   | LinkedIn profile URL                                                                                   |
| `job_title`      | Job title                                                                                              |
| `message`        | The last message in the thread                                                                         |
| `thread_url`     | Link to the conversation in MasterInbox                                                                |
| `campaign_name`  | Campaign name                                                                                          |
| `labels`         | The prospect's label names                                                                             |
| `custom_<field>` | A workspace custom field, by its field name                                                            |

Mind the spacing around variables — `Hi{{first_name ?? there}}` renders as `HiJohn`.

A complete, correct body:

```json
"email_body": "<p>Hi {{first_name ?? there}},</p><p>{{name ?? A lead}} just replied:</p><p>{{message}}</p><p><a href=\"{{thread_url}}\">Open the conversation</a></p>"
```

### CRM

| `action_type`     | Required                               |
| ----------------- | -------------------------------------- |
| `sync_to_hubspot` | — (needs HubSpot connected in the app) |

### Outbound tools

These take provider ids. **Fetch them first** — the ids are not guessable:

| Lookup                                     | Returns ids for                                                                        |
| ------------------------------------------ | -------------------------------------------------------------------------------------- |
| `GET /heyreach/campaigns`                  | `add_to_sequence_heyreach`, `remove_from_sequence_heyreach`                            |
| `GET /heyreach/lists`                      | `add_to_list_heyreach`, `remove_from_list_heyreach`                                    |
| `GET /emailbison/campaigns`                | `add_to_campaign_bison`, `remove_from_campaign_bison`                                  |
| `GET /plusvibe/campaigns`                  | `add_to_campaign_plusvibe`, `remove_from_campaign_plusvibe`                            |
| `GET /smartlead/campaigns`                 | all `*_smartlead` actions                                                              |
| `GET /smartlead/subsequences?campaign_id=` | `move_to_subsequence_smartlead`                                                        |
| `GET /instantly/campaigns`                 | `add_to_sequence_instantly`, `remove_from_sequence_instantly`, `delete_lead_instantly` |
| `GET /instantly/subsequences?campaign_id=` | `add_to_subsequence_instantly`, `remove_from_subsequence_instantly`                    |

| `action_type`                                                                                                                                                           | Required                                 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `add_to_sequence_heyreach`, `remove_from_sequence_heyreach`                                                                                                             | `campaign_ids`: string[]                 |
| `add_to_list_heyreach`, `remove_from_list_heyreach`                                                                                                                     | `list_ids`: string[]                     |
| `add_to_campaign_bison`, `remove_from_campaign_bison`                                                                                                                   | `campaign_ids`                           |
| `add_to_campaign_plusvibe`, `remove_from_campaign_plusvibe`                                                                                                             | `campaign_ids`                           |
| `add_to_sequence_instantly`, `remove_from_sequence_instantly`, `delete_lead_instantly`                                                                                  | `campaign_ids`                           |
| `add_to_subsequence_instantly`, `remove_from_subsequence_instantly`                                                                                                     | `campaign_ids` **and** `subsequence_ids` |
| `add_to_campaign_smartlead`, `remove_from_campaign_smartlead`, `unsubscribe_from_campaign_smartlead`, `pause_from_campaign_smartlead`, `resume_from_campaign_smartlead` | `campaign_ids`                           |
| `move_to_subsequence_smartlead`                                                                                                                                         | `campaign_ids` **and** `subsequence_ids` |

> ⚠️ **Some outbound actions depend on how the workspace connected the provider**, and the API cannot
> tell you which. On **Instantly**: `delete_lead_instantly` works only with an OAuth connection, while
> `remove_from_sequence_instantly` and `remove_from_subsequence_instantly` work only with an API-key
> connection. On **Smartlead**: `unsubscribe_from_campaign_smartlead` is API-key only,
> `move_to_subsequence_smartlead` is OAuth only. The workflow is accepted either way; a mismatch surfaces as a per-action failure at run time.

### Refused actions

Do not use these. They are valid in the app but not over the API:

| `action_type`                                                                              | Reason                                                                                    |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| `add_row_google_sheet`                                                                     | Spreadsheet / tab / column ids not discoverable via the API                               |
| `remove_from_workflow_close`, `update_opportunity_close`, `create_task_close`              | Close ids not discoverable via the API                                                    |
| `move_to_subsequence_heyreach`, `in_app_notification`, `remove_from_subsequence_smartlead` | Not implemented — no handler exists                                                       |
| `mark_as_read`, `upsert_prospect`, `set_custom_property_quickmail`                         | The app cannot display or edit these — a workflow holding one breaks in the app           |
| `unsubscribe_from_all_campaign_smartlead`                                                  | The app cannot display it — use `unsubscribe_from_campaign_smartlead` with `campaign_ids` |

---

## Looking up ids

| Id                                        | Endpoint                                                                  |
| ----------------------------------------- | ------------------------------------------------------------------------- |
| `label_ids`                               | `GET /get-labels`                                                         |
| `inbox_type_id` / `inbox_type_ids`        | `GET /get-inbox-types`                                                    |
| `channel_id` / `channel_ids`              | `POST /get-channels` — use each channel's **`_id`**, not its `channel_id` |
| `template_id` (Slack buttons)             | `GET /templates`                                                          |
| provider campaigns / lists / subsequences | see the outbound table above                                              |

`folder_id`, `enrich_with`, `reminder_minutes` and every enum filter value are fixed sets — they are
in `GET /workflows/filter-fields`, no lookup needed.

**`campaign_name` and `custom_fields` filters match on names, not ids.** There is no lookup for them.

---

## Rules the validator enforces

Beyond required fields, a workflow is rejected when it would fight itself:

- An action that re-triggers its own workflow — adding a label that triggers it, or moving to the list
  that triggered it.
- Two actions that contradict — adding and removing the same label; moving to and removing from the
  same list.
- Duplicate label ids inside one action.
- More than 10 actions, or none.
- Label, list or template ids that belong to another workspace.
- Channel ids that are not a channel `_id` in this workspace — in `channel_ids`, in `channel_id`
  conditions, and on `email_notify` / `forward_email`.
- An `email_body` with line breaks but no HTML, or a quoted variable fallback.

**Not checked:** whether two _separate_ workflows trigger each other — avoid building a pair where
each one's action fires the other's trigger — and the names in `payload_variables` /
`webhook_payload_variables` (an unknown one is sent as `null`).

---

## Building a workflow, end to end

1. `GET /workflows/filter-fields` — current triggers, actions, filter fields.
2. Look up any ids you need (labels, lists, channels, provider campaigns).
3. `POST /workflows/create-workflow`.
4. On `400`, fix **every** listed error and resend. On "configure this action in the app", stop and
   tell the user.
5. Confirm with `GET /workflows/{workflow_id}`.

New workflows are created active (`is_active: 1`). Pause with `change-workflow-status` rather than
deleting.
