Automations
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 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.
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" }
]
}'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);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 | |
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) |
|
lead_received |
Someone submits a Meta lead ad form. The run messages the phone number on the form | |
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.
#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 pendingwait_for_reply, or restarts the workflow. - A button with
workflowIdstarts 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 |
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 withGET .../versions/{version}and roll back withPOST .../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 .../duplicatecopies 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 |
List workflows |
POST /v1/workflows |
Create workflow |
GET /v1/workflows/{workflowId} |
Get workflow with graph |
PATCH /v1/workflows/{workflowId} |
Update workflow |
DELETE /v1/workflows/{workflowId} |
Delete workflow |
POST /v1/workflows/{workflowId}/activate |
Activate workflow |
POST /v1/workflows/{workflowId}/pause |
Pause workflow |
GET /v1/workflows/{workflowId}/executions |
List workflow runs |
POST /v1/workflows/{workflowId}/executions |
Manually start a workflow run |
POST /v1/workflows/{workflowId}/trigger |
Start an API-triggered workflow |
GET /v1/workflows/{workflowId}/executions/{executionId}/events |
Get an execution's timeline |
POST /v1/workflows/{workflowId}/duplicate |
Duplicate a workflow |
GET /v1/workflows/{workflowId}/versions |
List a workflow's version history |
GET /v1/workflows/{workflowId}/versions/{version} |
Get a specific workflow version |
POST /v1/workflows/{workflowId}/versions/{version}/restore |
Restore a workflow version |
#Webhook events
| Event | When it fires |
|---|---|
workflow.run.started |
Workflow run started. |
workflow.run.completed |
Workflow run completed. |
workflow.run.failed |
Workflow run failed. |