# Workflows

Build conversation flows that run on one messaging account when a trigger fires: send messages, wait for answers, branch, call your backend and hand off to a person.

A workflow is a graph of steps that runs on one messaging account. When its trigger fires (a DM, a link click, a comment, a story mention, a call from your backend) Pozy starts a **run** for that contact and walks the graph: send a message, wait for the reply, branch on it, tag the contact, call your webhook, hand off to a person.

Workflows run on WhatsApp, Instagram, Facebook, X, Bluesky and TikTok. Telegram is coming soon. For a simple keyword reply under a post, a [comment automation](https://pozy.ai/docs/automations/comment-to-dm) is less work.

## Your first workflow

This Instagram workflow answers anyone who writes "pricing", asks which plan they want, and tags them by answer. It is created as a draft, then activated.

```bash cURL
curl -X POST https://api.pozy.ai/v1/workflows \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profileId": "6650c1e2a3b4c5d6e7f80911",
    "accountId": "6650c1e2a3b4c5d6e7f80a22",
    "platform": "instagram",
    "name": "Pricing questions",
    "nodes": [
      { "id": "start", "type": "trigger", "config": { "triggerType": "inbound_message", "keywords": ["pricing", "price"], "matchType": "contains", "cooldownHours": 24 } },
      { "id": "ask", "type": "send_message", "config": { "messageType": "text", "text": "Happy to help, {{contact.name}}. Which plan are you looking at?",
        "quickReplies": [ { "title": "Solo", "payload": "PLAN_SOLO" }, { "title": "Team", "payload": "PLAN_TEAM" } ] } },
      { "id": "wait", "type": "wait_for_reply", "config": { "timeoutMinutes": 1440, "saveAs": "plan" } },
      { "id": "route", "type": "condition", "config": { "rules": [ { "id": "team", "variable": "quickReply.payload", "operator": "equals", "value": "PLAN_TEAM" } ] } },
      { "id": "tag_team", "type": "add_tag", "config": { "tag": "lead-team" } },
      { "id": "handoff", "type": "handoff", "config": { "note": "Team plan lead, reply within the hour" } },
      { "id": "solo", "type": "send_message", "config": { "messageType": "text", "text": "Solo is $12 a month. Start here: https://acme.example/solo" } },
      { "id": "done", "type": "end" }
    ],
    "edges": [
      { "id": "e1", "source": "start", "target": "ask" },
      { "id": "e2", "source": "ask", "target": "wait" },
      { "id": "e3", "source": "wait", "target": "route", "sourceHandle": "reply" },
      { "id": "e4", "source": "wait", "target": "done", "sourceHandle": "timeout" },
      { "id": "e5", "source": "route", "target": "tag_team", "sourceHandle": "team" },
      { "id": "e6", "source": "route", "target": "solo", "sourceHandle": "default" },
      { "id": "e7", "source": "tag_team", "target": "handoff" },
      { "id": "e8", "source": "solo", "target": "done" }
    ]
  }'
```
```ts Node
const createdRes = await fetch("https://api.pozy.ai/v1/workflows", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    profileId: "6650c1e2a3b4c5d6e7f80911",
    accountId: "6650c1e2a3b4c5d6e7f80a22",
    platform: "instagram",
    name: "Pricing questions",
    nodes: [
      { id: "start", type: "trigger", config: { triggerType: "inbound_message", keywords: ["pricing", "price"], matchType: "contains", cooldownHours: 24 } },
      { id: "ask", type: "send_message", config: { messageType: "text", text: "Happy to help, {{contact.name}}. Which plan are you looking at?",
        quickReplies: [{ title: "Solo", payload: "PLAN_SOLO" }, { title: "Team", payload: "PLAN_TEAM" }] } },
      { id: "wait", type: "wait_for_reply", config: { timeoutMinutes: 1440, saveAs: "plan" } },
      { id: "route", type: "condition", config: { rules: [{ id: "team", variable: "quickReply.payload", operator: "equals", value: "PLAN_TEAM" }] } },
      { id: "tag_team", type: "add_tag", config: { tag: "lead-team" } },
      { id: "handoff", type: "handoff", config: { note: "Team plan lead, reply within the hour" } },
      { id: "solo", type: "send_message", config: { messageType: "text", text: "Solo is $12 a month. Start here: https://acme.example/solo" } },
      { id: "done", type: "end" },
    ],
    edges: [
      { id: "e1", source: "start", target: "ask" },
      { id: "e2", source: "ask", target: "wait" },
      { id: "e3", source: "wait", target: "route", sourceHandle: "reply" },
      { id: "e4", source: "wait", target: "done", sourceHandle: "timeout" },
      { id: "e5", source: "route", target: "tag_team", sourceHandle: "team" },
      { id: "e6", source: "route", target: "solo", sourceHandle: "default" },
      { id: "e7", source: "tag_team", target: "handoff" },
      { id: "e8", source: "solo", target: "done" },
    ],
  }),
});
const created = await createdRes.json();
if (!createdRes.ok) throw new Error(created.error);

const res = await fetch(`https://api.pozy.ai/v1/workflows/${created.workflow.id}/activate`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error);
```
```python Python
import os
import requests

H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}
workflow = {
    "profileId": "6650c1e2a3b4c5d6e7f80911",
    "accountId": "6650c1e2a3b4c5d6e7f80a22",
    "platform": "instagram",
    "name": "Pricing questions",
    "nodes": [
        {"id": "start", "type": "trigger", "config": {"triggerType": "inbound_message", "keywords": ["pricing", "price"], "matchType": "contains"}},
        {"id": "ask", "type": "send_message", "config": {"messageType": "text", "text": "Which plan are you looking at?",
            "quickReplies": [{"title": "Solo", "payload": "PLAN_SOLO"}, {"title": "Team", "payload": "PLAN_TEAM"}]}},
        {"id": "wait", "type": "wait_for_reply", "config": {"timeoutMinutes": 1440, "saveAs": "plan"}},
        {"id": "done", "type": "end"},
    ],
    "edges": [
        {"id": "e1", "source": "start", "target": "ask"},
        {"id": "e2", "source": "ask", "target": "wait"},
        {"id": "e3", "source": "wait", "target": "done", "sourceHandle": "reply"},
        {"id": "e4", "source": "wait", "target": "done", "sourceHandle": "timeout"},
    ],
}
created = requests.post("https://api.pozy.ai/v1/workflows", headers=H, json=workflow, timeout=30).json()
wid = created["workflow"]["id"]
requests.post(f"https://api.pozy.ai/v1/workflows/{wid}/activate", headers=H, timeout=30)
```

The create call returns the draft with its `id`. Only an `active` workflow starts runs, so a workflow that "does nothing" is usually still `draft` or `paused`.

## Lifecycle

| Status | Meaning | Move with |
| --- | --- | --- |
| `draft` | Saved, not running | `POST /v1/workflows/{id}/activate` |
| `active` | Starts runs when its trigger fires | `POST /v1/workflows/{id}/pause` |
| `paused` | Starts no new runs. Runs already in progress finish | `POST /v1/workflows/{id}/activate` |

Creating validates the structure: node ids must be unique, edges must point at existing nodes, and WhatsApp-only nodes cannot appear on other platforms. Activating also requires exactly one trigger node and a graph where every node can be reached.

## Triggers

Every workflow has one `trigger` node. Its `config.triggerType` decides what starts a run.

| `triggerType` | Starts when | Platforms |
| --- | --- | --- |
| `inbound_message` (default) | The account receives a DM. Filter with `keywords` and `matchType` (`any`, `contains`, `exact`, `regex`). `onlyFirstMessage: true` fires once per contact | All workflow platforms |
| `referral` | Someone opens the chat from an ig.me or m.me link with a `ref`, or a click-to-message ad. Narrow with `referral: { ref, matchType }` (`exact`, `prefix`, `any`) | Instagram, Facebook, WhatsApp |
| `comment` | Someone comments on a post. Filter with `comment: { keywords, matchType, platformPostId }` | Instagram, Facebook |
| `story_mention` | Someone mentions the account in their story | Instagram |
| `reaction` | Someone reacts to a message. Filter with `reaction: { emojis }` | Instagram, Facebook, WhatsApp, Telegram groups |
| `whatsapp_event` | A status arrives for a message the account sent (`eventType`: `message_sent`, `message_delivered`, `message_read`, `message_failed`, `reaction`) | WhatsApp |
| `lead_received` | Someone submits a Meta lead ad form. The run messages the phone number on the form | WhatsApp |
| `api_call` | Your backend starts a run | All workflow platforms |

**Cooldown.** Within `cooldownHours` (1 to 720, 24 by default) of a run, the same contact cannot start the workflow again. Every trigger respects it except `comment` and `api_call`. If a platform delivers one event twice, the workflow still starts only once.

**Comment runs** have no conversation yet. Put a `send_message` right after the trigger: it is sent as the private reply to the comment, and the rest of the run happens in the chat that reply creates. That message can be text, with buttons or quick replies if you like, or cards. For commenters who do not follow you, Instagram delivers plain text only, so keep it simple. When an active comment automation also matches the comment, the automation replies and the workflow stays idle.

**Lead runs** start only for leads submitted in the last hour, after activation, that ticked the consent checkbox named in `leadReceived.consentCheckboxKey` and whose phone number parses. Scope them with `formIds` or `pageAccountIds`. Every message the run sends before the person answers must be an approved WhatsApp template, because they have never written to you.

## Graph, edges and branches

`nodes[]` and `edges[]` make the graph. Each node has a stable `id`, a `type` and a `config`; an optional `label` (up to 80 characters) and `position` are for the visual builder. Each edge links `source` to `target`. A node with several outcomes picks the edge whose `sourceHandle` matches:

| Node | `sourceHandle` values |
| --- | --- |
| `condition` | The id of the first rule that matched, or `default` |
| `wait_for_reply` | `reply` or `timeout` |
| `a_b_split` | `a` or `b` |
| `webhook`, `hubspot_upsert_contact`, `enroll_sequence` | `success` or `error` |
| `ai` | `success`, `error`, or `tool:<name>` when the model picks a tool |
| `start_call` | `success`, `permission_required` or `failed` |

An edge without `sourceHandle` is the node's only or default output. Every node type and its fields are on [Workflow nodes](https://pozy.ai/docs/automations/workflow-nodes).

## Variables

Strings in a node's `config` may include `{{variable}}`. The variables below are set at the start of each run; any the trigger did not provide are empty strings:

| Group | Variables |
| --- | --- |
| Message | `lastMessage`, `inboundText`, `triggerText`, `conversationId` |
| Contact | `contact.name`, `contact.phone`, `contact.email`, `contact.handle`, `contact.tags`, `contact.fields`, `contact.lastInteractionAt`, `contact.lastInteractionHoursAgo`, `contact.isFollower` (Instagram) |
| Referral | `referral.ref`, `referral.source`, `referral.type`, `referral.adId`, `referral.adTitle`, `referral.adName`, `referral.campaignName` |
| Taps | `postback.payload`, `postback.title`, `quickReply.payload`, `quickReply.title` |
| Story, comment, reaction | `story.id`, `story.url`, `comment.id`, `comment.text`, `comment.platformPostId`, `reaction.emoji`, `reaction.action` |
| Lead | `lead.id`, `lead.formId`, `lead.formName`, `lead.adId`, `lead.campaignId` and every answer by its key, such as `lead.full_name` and `lead.email` |

Values captured with `saveAs` and every `set_variable` assignment join the same set. `api_call` runs also get the `variables` you send. An array or object interpolated into text renders as JSON.

## Buttons that start or answer workflows

On Instagram and Facebook, buttons and quick replies connect workflows to each other:

- A postback button or quick reply **with no payload** gets `pozy:workflow:<this workflow id>`. A tap answers this run's pending `wait_for_reply`, or restarts the workflow.
- A button with **`workflowId`** starts that other workflow instead.
- Anywhere else (persistent menus, ice breakers, the Get Started button, comment automation DMs), a payload of `pozy:workflow:<workflowId>` starts that workflow when tapped. It must be active on the same account and profile.

A tap on a different active workflow starts it and exits the contact's other live runs.

## Start a run from your backend

An `api_call` workflow runs when you ask it to. `POST /v1/workflows/{workflowId}/trigger` takes exactly one target plus optional `variables`:

| Target | Runs in |
| --- | --- |
| `conversationId` | That conversation on the workflow's account |
| `contactId` | The contact's conversation on the workflow's account |
| `to` | WhatsApp only: a phone number in E.164. The conversation is found or created |

```bash
curl -X POST https://api.pozy.ai/v1/workflows/6650c1e2a3b4c5d6e7f80d55/trigger \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contactId": "6650c1e2a3b4c5d6e7f80c44", "variables": { "orderId": "A-1042", "carrier": "UPS" } }'
```

It answers `201` with `{ "executionId": "..." }`. Inside the run, `{{orderId}}` and `{{carrier}}` are available.

## Test a workflow

`POST /v1/workflows/{workflowId}/executions` starts a run without a real inbound message. Pass `conversationId` (or `to` on WhatsApp) and an optional `text` that becomes `lastMessage`. Use it with a conversation from your own test account.

## Follow runs

| Call | Returns |
| --- | --- |
| `GET /v1/workflows/{id}/executions` | Runs with `status` (`running`, `waiting`, `completed`, `exited`, `failed`), the current node, what it is waiting for and the accumulated variables |
| `GET /v1/workflows/{id}/executions/{executionId}/events` | The run's timeline: every node started, completed, failed or skipped, the edge taken and the duration |

`exited` is a deliberate early end, before the last node: a `handoff`, or a button that sent the contact into another workflow. Rather than polling, listen for `workflow.run.started`, `workflow.run.completed` and `workflow.run.failed`; the failed event says which node broke and why.

## Edit, version and copy

- `PATCH /v1/workflows/{id}` changes the name, description, account or graph. Every graph edit is validated, and moving the workflow to another account re-checks it against that platform. A run already in progress picks up the saved graph at its next step; a run waiting on a node you removed completes instead of failing. For big changes, pause first and activate after.
- Each graph change records a version. List them with `GET .../versions`, read one with `GET .../versions/{version}` and roll back with `POST .../versions/{version}/restore`. Restoring needs the workflow paused or in draft, and snapshots the current graph first, so a restore can itself be undone.
- `POST .../duplicate` copies the graph, name and account into a new draft with no run history. Use it to experiment without touching the live version.

The `nodes` array is replaced as a whole on update, so always send the full list.

## Errors

| Error | Cause |
| --- | --- |
| `400` on create or update | Invalid structure: duplicate node ids, edges to missing nodes, a WhatsApp-only node on another platform, buttons on a platform that does not support them |
| `400` on activate | No trigger node, nodes that cannot be reached, or an incomplete graph. The `error` message names the problem |
| `400` on restore | The workflow is active. Pause it first |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/workflows`](https://pozy.ai/docs/api/listWorkflows) | List workflows |
| [`POST /v1/workflows`](https://pozy.ai/docs/api/createWorkflow) | Create workflow |
| [`GET /v1/workflows/{workflowId}`](https://pozy.ai/docs/api/getWorkflow) | Get workflow with graph |
| [`PATCH /v1/workflows/{workflowId}`](https://pozy.ai/docs/api/updateWorkflow) | Update workflow |
| [`DELETE /v1/workflows/{workflowId}`](https://pozy.ai/docs/api/deleteWorkflow) | Delete workflow |
| [`POST /v1/workflows/{workflowId}/activate`](https://pozy.ai/docs/api/activateWorkflow) | Activate workflow |
| [`POST /v1/workflows/{workflowId}/pause`](https://pozy.ai/docs/api/pauseWorkflow) | Pause workflow |
| [`GET /v1/workflows/{workflowId}/executions`](https://pozy.ai/docs/api/listWorkflowExecutions) | List workflow runs |
| [`POST /v1/workflows/{workflowId}/executions`](https://pozy.ai/docs/api/triggerWorkflow) | Manually start a workflow run |
| [`POST /v1/workflows/{workflowId}/trigger`](https://pozy.ai/docs/api/triggerApiCallWorkflow) | Start an API-triggered workflow |
| [`GET /v1/workflows/{workflowId}/executions/{executionId}/events`](https://pozy.ai/docs/api/listWorkflowExecutionEvents) | Get an execution's timeline |
| [`POST /v1/workflows/{workflowId}/duplicate`](https://pozy.ai/docs/api/duplicateWorkflow) | Duplicate a workflow |
| [`GET /v1/workflows/{workflowId}/versions`](https://pozy.ai/docs/api/listWorkflowVersions) | List a workflow's version history |
| [`GET /v1/workflows/{workflowId}/versions/{version}`](https://pozy.ai/docs/api/getWorkflowVersion) | Get a specific workflow version |
| [`POST /v1/workflows/{workflowId}/versions/{version}/restore`](https://pozy.ai/docs/api/restoreWorkflowVersion) | Restore a workflow version |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`workflow.run.started`](https://pozy.ai/docs/webhooks/events#workflow-run-started) | Workflow run started. |
| [`workflow.run.completed`](https://pozy.ai/docs/webhooks/events#workflow-run-completed) | Workflow run completed. |
| [`workflow.run.failed`](https://pozy.ai/docs/webhooks/events#workflow-run-failed) | Workflow run failed. |
