Workflows
Automate onboarding, drip and event-driven email: a workflow is a graph of nodes — send, wait, branch, call a webhook — that each enrolled contact walks through on their own.
How a workflow runs#
Create the workflow
POST /workflowswith a name and a trigger type. It starts asdraftwith one node already in its graph: atriggernode.Save its graph
PUT /workflows/:id/graphwith every node and edge. You always send the whole graph; there is no per-node endpoint.Activate it
POST /workflows/:id/activatechecks the graph (the same rulesPOST /workflows/:id/validatereports) and refuses an invalid one with 422. From then on, the trigger enrols contacts.
Each enrolment is a run: one contact walking the graph from the trigger node, one node at a time, following edges. A run ends as completed when it reaches a node with no way forward, failed when a node throws (a send is refused, a webhook still fails after its retries), or stopped when the node it was on is removed from the graph. Apart from webhook calls (see webhook), a failed step is not retried — the run ends there. A run that visits 1,000 nodes is failed as a safety stop.
Pausing and editing
- Pause stops new enrolments and holds runs already in progress at their next step: nothing more is sent or called, and the runs stay
running(they do not fail). A run in awaitis held when the wait ends. A step that had already started when you paused finishes. - Activate again and every held run continues from the node it stopped at. A run whose wait has not yet ended waits out the rest of it, on its original timing. Each node runs once per run: resuming never repeats the step before or after a wait.
- The graph can only be saved while the workflow is
draftorpaused(409 otherwise). Nodes are matched bypublic_id: a node you keep (even with a changed config) keeps its held runs, which resume there and continue along the new edges. A run held on a node you remove isstoppedwhen you save. - Deleting needs the workflow not to be
active, and deletes its run history with it.
Triggers#
| trigger_type | Enrols a contact when… |
|---|---|
contact.created | A contact is created with POST /contacts, or a signup form submission creates a new one. Contacts added by CSV import do not enrol. |
contact.subscribed | A signup form submission subscribes the contact to a topic — including an existing contact re-subscribing. |
custom | Your application posts an event to POST /workflows/events whose name equals the workflow's trigger_config.eventName (exact, case-sensitive). |
manual | You call POST /workflows/:id/trigger for a specific contact. |
Trigger settings
trigger_config.eventNamestringcustomonly. The event this workflow listens for. Acustomworkflow without one matches no event.trigger_config.segmentIdstring- Optional entry filter (
seg_…) forcontact.created,contact.subscribedandcustom: only contacts in this segment enrol. If the segment is deleted, nobody enrols. Manual triggers ignore it.
Re-entry
Event triggers (contact.created, contact.subscribed, custom) skip a contact who already has a running run in the same workflow, so a repeated event does not start the sequence twice in parallel. Once that run has ended, the next event enrols them again. POST /workflows/:id/trigger always starts a new run.
Nodes and edges#
Every node in a graph has:
public_idstringrequired- 1–64 characters, unique within the graph. You choose it; edges refer to nodes by it.
type"trigger" | "send_email" | "wait" | "condition" | "webhook"required- What the node does.
configobjectrequired- Per-type settings, below. Not checked when you save the graph — a bad config fails the run when a contact reaches the node.
canvas_x / canvas_ynumberrequired- Position on the dashboard canvas. Ignored by the engine.
An edge is { from_public_id, to_public_id, branch }. branch is null (or omitted) for an ordinary edge, and "match" or "no_match" for the two ways out of a condition.
trigger
Where every run starts; it does nothing itself. Config: { "triggerType": "contact.created", "segmentId": "seg_…" } (segmentId optional). Exactly one per graph, no incoming edges, at most one outgoing edge. If it has no outgoing edge, runs complete immediately.
send_email
templateIdstringrequired- A
tpl_…template. It must exist and be published when you activate the workflow; it is not re-checked at send time. fromAddressstring- Sender, e.g.
hello@yourdomain.comorAcme <hello@yourdomain.com>. Defaults tonoreply@your team's oldest verified domain; with no verified domain the step fails. fromNamestring- Display name, used only when
fromAddressis a bare address.
The email goes to the contact's address through the normal send pipeline (tracking, quota, webhooks). If the address is suppressed, the send is refused and the run fails.
The template is rendered exactly like an API send with template_id (see Templates), with the contact supplying the variables:
{{first_name}},{{last_name}}and{{email}}from the contact;- every contact property by its key, e.g.
{{plan}}(numbers and booleans as text); {{#if var}}…{{/if}}and{{#unless var}}…{{/unless}}blocks, where an unset or empty value is false;{{unsubscribe_url}},{{preferences_url}}and{{signature}}, filled in per recipient by the send.
Tag names are case-sensitive. A tag with no value becomes empty. Values are HTML-escaped in the HTML body and inserted as-is in the subject and text body. If the rendered subject is empty, the template's name is used.
wait
| Config | Waits |
|---|---|
{ "duration": 3, "unit": "days" } | A fixed time. unit is hours or days; duration must be greater than 0. |
{ "mode": "until", "untilTime": "09:00" } | Until the next 09:00 UTC — today if that is still ahead, otherwise tomorrow. 24-hour HH:MM. |
{ "mode": "until", "untilDate": "2026-12-01T09:00:00Z" } | Until that instant. A datetime without a timezone is read as UTC; a time in the past continues at once. |
With mode: until, untilTime wins if both are set. A wait with nothing after it ends the run immediately rather than waiting.
condition
fieldstringrequiredemail,firstName,lastName,unsubscribed(compares as"true"/"false"), or any other name, which is read from the contact's properties. Number and boolean properties are compared as strings; a missing property is the empty string.comparator"equals" | "not_equals" | "contains" | "not_contains" | "is_set" | "is_not_set"required- Case-sensitive string comparison.
is_setmeans non-empty. An unknown comparator always takesno_match. valuestringrequired- What to compare against. Required even for
is_set/is_not_set(pass"") — without it the run fails at this node.
A true comparison follows the match edge, a false one the no_match edge. A condition may have at most one of each; if the chosen branch has no edge, the run completes there.
webhook
Config { "url": "https://…" }. PostStack POSTs this JSON with Content-Type: application/json and User-Agent: PostStack-Workflow/1.0:
{
"event": "workflow.webhook",
"workflow": { "id": "wf_5gt1y8u3i6o0p4a7s2d9f6hj", "name": "Order follow-up" },
"contact": {
"id": "con_8rb2n5m1v7c4x0z6l3k9j2hq",
"email": "ada@example.com",
"first_name": "Ada",
"last_name": null,
"properties": { "plan": "pro" }
}
}The request is not signed. URLs that resolve to private or reserved addresses are refused, redirects are not followed, and each attempt times out after 10 seconds. A network error, a timeout, a 5xx, 408 or 429 is retried: up to 3 attempts in total, 1 then 2 seconds apart. Any other non-2xx response, or a refused URL, fails the run at once; so does a third failed attempt. Because of retries your endpoint can receive the same call more than once.
Graph rules
Saving checks only that node ids are unique, every edge joins two different, existing nodes, and no node has two edges with the same label (two unlabelled edges, or two match/no_match edges); anything else is a 422. Activation (and validate) also requires:
- exactly one
triggernode, with no incoming edges and at most one outgoing edge; - no cycles;
- every node reachable from the trigger;
- only conditions branch: other nodes have at most one outgoing edge, unlabelled; condition edges are all labelled, with at most one
matchand oneno_match; - every
send_emailnode names an existing, published template.
Several edges may point at the same node, so branches can merge again.
Example: an order follow-up#
Thank the buyer, wait three days, then notify your CRM about pro customers and ask everyone else for a review.
// 1. Create it. The event name for a custom trigger goes in trigger_config.
const workflow = await poststack.workflows.create({
name: 'Order follow-up',
trigger_type: 'custom',
trigger_config: { eventName: 'order.placed' },
});
// 2. Save the graph (replaces the seeded trigger node).
await poststack.workflows.putGraph(workflow.publicId, {
"nodes": [
{ "public_id": "trigger", "type": "trigger", "config": { "triggerType": "custom" }, "canvas_x": 250, "canvas_y": 0 },
{ "public_id": "thanks", "type": "send_email", "config": { "templateId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds" }, "canvas_x": 250, "canvas_y": 120 },
{ "public_id": "wait3d", "type": "wait", "config": { "duration": 3, "unit": "days" }, "canvas_x": 250, "canvas_y": 240 },
{ "public_id": "is_pro", "type": "condition", "config": { "field": "plan", "comparator": "equals", "value": "pro" }, "canvas_x": 250, "canvas_y": 360 },
{ "public_id": "notify_crm", "type": "webhook", "config": { "url": "https://hooks.example.com/poststack" }, "canvas_x": 100, "canvas_y": 480 },
{ "public_id": "review", "type": "send_email", "config": { "templateId": "tpl_0km4j7h1g8f5d2s9a6q3w0ex" }, "canvas_x": 400, "canvas_y": 480 }
],
"edges": [
{ "from_public_id": "trigger", "to_public_id": "thanks", "branch": null },
{ "from_public_id": "thanks", "to_public_id": "wait3d", "branch": null },
{ "from_public_id": "wait3d", "to_public_id": "is_pro", "branch": null },
{ "from_public_id": "is_pro", "to_public_id": "notify_crm", "branch": "match" },
{ "from_public_id": "is_pro", "to_public_id": "review", "branch": "no_match" }
]
});
// 3. Check it, then go live.
const check = await poststack.workflows.validateGraph(workflow.publicId);
if (!check.valid) throw new Error(check.errors.join('\n'));
await poststack.workflows.activate(workflow.publicId);
// 4. From your checkout code:
const { matched } = await poststack.workflows.postEvent({
event: 'order.placed',
email: 'ada@example.com',
});
if (matched === 0) console.warn('No active workflow listens for order.placed');Endpoints#
All paths are relative to https://api.poststack.dev, and all need the workflows:manage scope (full-access API keys have it).
Create a workflow#
Create a draft workflow. Its graph starts with one trigger node (config { triggerType }, plus segmentId if you passed one).
https://api.poststack.dev/workflows// The SDK unwraps the { workflow } envelope.
const workflow = await poststack.workflows.create({
name: 'Order follow-up',
trigger_type: 'custom',
trigger_config: { eventName: 'order.placed' },
});curl -X POST https://api.poststack.dev/workflows \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Welcome sequence", "trigger_type": "contact.created" }'{
"name": "Order follow-up",
"trigger_type": "custom",
"trigger_config": { "eventName": "order.placed" }
}HTTP/1.1 201 Created
{
"workflow": {
"id": 9,
"publicId": "wf_5gt1y8u3i6o0p4a7s2d9f6hj",
"teamId": 42,
"name": "Order follow-up",
"triggerType": "custom",
"triggerConfig": { "eventName": "order.placed" },
"status": "draft",
"createdAt": "2026-03-23T10:00:00.000Z",
"updatedAt": "2026-03-23T10:00:00.000Z"
}
}Body parameters
namestringrequired- 1–200 characters, unique within your team.
trigger_type"contact.created" | "contact.subscribed" | "custom" | "manual"required- See Triggers.
trigger_configobject- Optional:
eventName(custom) and/orsegmentId.
Response fields
workflow.idinteger- Internal numeric id. Address the workflow by publicId.
workflow.publicIdstring- The wf_… id every other endpoint takes.
workflow.namestring- Unique within your team.
workflow.triggerType"contact.created" | "contact.subscribed" | "custom" | "manual"- What enrols contacts.
workflow.triggerConfigobject | null- eventName and/or segmentId — see Triggers.
workflow.status"draft" | "active" | "paused"- Only active workflows enrol contacts.
workflow.createdAt / updatedAtstring- ISO 8601.
Status codes
| Status | Meaning |
|---|---|
| 201 | Workflow created. |
| 400 | Body failed validation. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 409 | A workflow with that name already exists. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
List workflows#
Every workflow in your team, oldest first, with node and run counts. Not paginated.
https://api.poststack.dev/workflows// Returns the array directly.
const workflows = await poststack.workflows.list();curl https://api.poststack.dev/workflows \
-H "Authorization: Bearer sk_live_..."{
"data": [
{
"id": 9,
"publicId": "wf_5gt1y8u3i6o0p4a7s2d9f6hj",
"name": "Order follow-up",
"triggerType": "custom",
"status": "active",
"stepsCount": 5,
"runsCount": 128,
"createdAt": "2026-03-23T10:00:00.000Z",
"updatedAt": "2026-03-23T10:03:00.000Z"
}
]
}Response fields
data[].id / publicId / name / triggerType / status…- As on the workflow.
data[].stepsCountinteger- Nodes in the graph, not counting the trigger.
data[].runsCountinteger- Runs ever started, in any state.
data[].createdAt / updatedAtstring- ISO 8601.
Status codes
| Status | Meaning |
|---|---|
| 200 | The workflows. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
Get a workflow#
One workflow and its run count. The graph is at GET /workflows/:id/graph.
https://api.poststack.dev/workflows/:idconst workflow = await poststack.workflows.get('wf_5gt1y8u3i6o0p4a7s2d9f6hj');curl https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj \
-H "Authorization: Bearer sk_live_..."{
"workflow": {
"id": 9,
"publicId": "wf_5gt1y8u3i6o0p4a7s2d9f6hj",
"teamId": 42,
"name": "Order follow-up",
"triggerType": "custom",
"triggerConfig": { "eventName": "order.placed" },
"status": "active",
"runsCount": 128,
"createdAt": "2026-03-23T10:00:00.000Z",
"updatedAt": "2026-03-23T10:03:00.000Z"
}
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Response fields
workflow.idinteger- Internal numeric id. Address the workflow by publicId.
workflow.publicIdstring- The wf_… id every other endpoint takes.
workflow.namestring- Unique within your team.
workflow.triggerType"contact.created" | "contact.subscribed" | "custom" | "manual"- What enrols contacts.
workflow.triggerConfigobject | null- eventName and/or segmentId — see Triggers.
workflow.status"draft" | "active" | "paused"- Only active workflows enrol contacts.
workflow.createdAt / updatedAtstring- ISO 8601.
workflow.runsCountinteger- Runs ever started.
Status codes
| Status | Meaning |
|---|---|
| 200 | The workflow. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
Update a workflow#
Rename it or change its trigger. trigger_config is replaced as a whole, not merged. Changing trigger_type needs the workflow not to be active; renaming and changing trigger_config work in any status.
https://api.poststack.dev/workflows/:idconst workflow = await poststack.workflows.update('wf_5gt1y8u3i6o0p4a7s2d9f6hj', {
trigger_config: { eventName: 'order.placed', segmentId: 'seg_4hd8s1k0q7m2x9w5c3v6b1nz' },
});curl -X PATCH https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Order follow-up v2" }'{
"trigger_config": { "eventName": "order.placed", "segmentId": "seg_4hd8s1k0q7m2x9w5c3v6b1nz" }
}{
"workflow": {
"id": 9,
"publicId": "wf_5gt1y8u3i6o0p4a7s2d9f6hj",
"teamId": 42,
"name": "Order follow-up",
"triggerType": "custom",
"triggerConfig": { "eventName": "order.placed" },
"status": "draft",
"createdAt": "2026-03-23T10:00:00.000Z",
"updatedAt": "2026-03-23T10:00:00.000Z"
}
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Body parameters
namestring- 1–200 characters.
trigger_type"contact.created" | "contact.subscribed" | "custom" | "manual"- New trigger.
trigger_configobject | null- New trigger settings; null clears them.
Response fields
workflow.idinteger- Internal numeric id. Address the workflow by publicId.
workflow.publicIdstring- The wf_… id every other endpoint takes.
workflow.namestring- Unique within your team.
workflow.triggerType"contact.created" | "contact.subscribed" | "custom" | "manual"- What enrols contacts.
workflow.triggerConfigobject | null- eventName and/or segmentId — see Triggers.
workflow.status"draft" | "active" | "paused"- Only active workflows enrol contacts.
workflow.createdAt / updatedAtstring- ISO 8601.
Status codes
| Status | Meaning |
|---|---|
| 200 | Updated (or unchanged, if the body was empty). |
| 400 | Body failed validation. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
| 409 | A workflow with that name already exists. |
| 422 | Pause the workflow before changing its trigger type. |
Get the graph#
The workflow's nodes (ordered top to bottom by canvas_y) and edges.
https://api.poststack.dev/workflows/:id/graphconst graph = await poststack.workflows.getGraph('wf_5gt1y8u3i6o0p4a7s2d9f6hj');curl https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj/graph \
-H "Authorization: Bearer sk_live_..."{
"graph": {
"nodes": [
{ "public_id": "wfn_2ck9v4b7n0m3x6z1l8k5j2hg", "type": "trigger", "config": { "triggerType": "custom" }, "canvas_x": 250, "canvas_y": 0 }
],
"edges": []
}
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Response fields
graph.nodes[]object[]- public_id, type, config, canvas_x, canvas_y.
graph.edges[]object[]- from_public_id, to_public_id, branch (null, "match" or "no_match").
Status codes
| Status | Meaning |
|---|---|
| 200 | The graph. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
Save the graph#
Save the whole graph in one transaction: send every node and edge, including the trigger node. Only allowed while the workflow is draft or paused. Nodes are matched by public_id: a kept node keeps any runs held on it, and a run held on a node you leave out is stopped. Structural rules are not enforced here — they gate activation.
https://api.poststack.dev/workflows/:id/graphconst graph = await poststack.workflows.putGraph('wf_5gt1y8u3i6o0p4a7s2d9f6hj', {
nodes: [
{ public_id: 'trigger', type: 'trigger', config: { triggerType: 'contact.created' }, canvas_x: 250, canvas_y: 0 },
{ public_id: 'welcome', type: 'send_email', config: { templateId: 'tpl_2wx7c4v9b1n6m3k8q5z0p7ds' }, canvas_x: 250, canvas_y: 120 },
],
edges: [{ from_public_id: 'trigger', to_public_id: 'welcome' }],
});curl -X PUT https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj/graph \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"nodes": [
{ "public_id": "trigger", "type": "trigger", "config": { "triggerType": "contact.created" }, "canvas_x": 250, "canvas_y": 0 },
{ "public_id": "welcome", "type": "send_email", "config": { "templateId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds" }, "canvas_x": 250, "canvas_y": 120 }
],
"edges": [ { "from_public_id": "trigger", "to_public_id": "welcome" } ]
}'{
"nodes": [
{ "public_id": "trigger", "type": "trigger", "config": { "triggerType": "custom" }, "canvas_x": 250, "canvas_y": 0 },
{ "public_id": "thanks", "type": "send_email", "config": { "templateId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds" }, "canvas_x": 250, "canvas_y": 120 },
{ "public_id": "wait3d", "type": "wait", "config": { "duration": 3, "unit": "days" }, "canvas_x": 250, "canvas_y": 240 },
{ "public_id": "is_pro", "type": "condition", "config": { "field": "plan", "comparator": "equals", "value": "pro" }, "canvas_x": 250, "canvas_y": 360 },
{ "public_id": "notify_crm", "type": "webhook", "config": { "url": "https://hooks.example.com/poststack" }, "canvas_x": 100, "canvas_y": 480 },
{ "public_id": "review", "type": "send_email", "config": { "templateId": "tpl_0km4j7h1g8f5d2s9a6q3w0ex" }, "canvas_x": 400, "canvas_y": 480 }
],
"edges": [
{ "from_public_id": "trigger", "to_public_id": "thanks", "branch": null },
{ "from_public_id": "thanks", "to_public_id": "wait3d", "branch": null },
{ "from_public_id": "wait3d", "to_public_id": "is_pro", "branch": null },
{ "from_public_id": "is_pro", "to_public_id": "notify_crm", "branch": "match" },
{ "from_public_id": "is_pro", "to_public_id": "review", "branch": "no_match" }
]
}{
"graph": { "nodes": [ ... ], "edges": [ ... ] }
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Body parameters
nodesobject[]required- Up to 500 nodes. See Nodes and edges.
edgesobject[]required- Up to 1000 edges.
Response fields
graph{ nodes, edges }- The saved graph, as Get the graph returns it.
Status codes
| Status | Meaning |
|---|---|
| 200 | Saved. |
| 400 | Body failed validation (unknown node type, public_id over 64 characters, too many nodes…). |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
| 409 | The workflow is active. Pause it first — pausing holds its runs, it does not end them. |
| 422 | Duplicate node id, Edge references a node not in the graph, An edge cannot connect a node to itself, or a node with two edges of the same label (e.g. two unlabelled edges). Nothing is saved. |
| 429 | More than 60 requests a minute to this endpoint from one key. Honour Retry-After. |
Validate the graph#
Check the saved graph against the activation rules (see Graph rules) without changing anything.
https://api.poststack.dev/workflows/:id/validateconst result = await poststack.workflows.validateGraph('wf_5gt1y8u3i6o0p4a7s2d9f6hj');
if (!result.valid) console.error(result.errors);curl -X POST https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj/validate \
-H "Authorization: Bearer sk_live_..."{
"valid": false,
"errors": [
"Node \"review\" is not reachable from the trigger.",
"Send-email node \"thanks\" references template \"tpl_2wx7c4v9b1n6m3k8q5z0p7ds\", which is not published."
]
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Response fields
validboolean- Whether activate would accept the graph.
errorsstring[]- Present only when valid is false; one sentence per problem.
Status codes
| Status | Meaning |
|---|---|
| 200 | The result (valid or not). |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
Activate a workflow#
Validate the graph and, if it passes, set the workflow active so its trigger starts enrolling contacts. Runs held while the workflow was paused continue from where they stopped. Contacts are not enrolled retroactively.
https://api.poststack.dev/workflows/:id/activateconst workflow = await poststack.workflows.activate('wf_5gt1y8u3i6o0p4a7s2d9f6hj');curl -X POST https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj/activate \
-H "Authorization: Bearer sk_live_..."{
"workflow": {
"publicId": "wf_5gt1y8u3i6o0p4a7s2d9f6hj",
"status": "active",
"...": "every other field, as in Create"
}
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Response fields
workflow.idinteger- Internal numeric id. Address the workflow by publicId.
workflow.publicIdstring- The wf_… id every other endpoint takes.
workflow.namestring- Unique within your team.
workflow.triggerType"contact.created" | "contact.subscribed" | "custom" | "manual"- What enrols contacts.
workflow.triggerConfigobject | null- eventName and/or segmentId — see Triggers.
workflow.status"draft" | "active" | "paused"- Only active workflows enrol contacts.
workflow.createdAt / updatedAtstring- ISO 8601.
Status codes
| Status | Meaning |
|---|---|
| 200 | Active. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
| 422 | Workflow graph is invalid: followed by every validation error. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
Pause a workflow#
Stop enrolling new contacts and hold runs already in progress at their next step. Held runs stay running and continue when you activate the workflow again.
https://api.poststack.dev/workflows/:id/pauseconst workflow = await poststack.workflows.pause('wf_5gt1y8u3i6o0p4a7s2d9f6hj');curl -X POST https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj/pause \
-H "Authorization: Bearer sk_live_..."{
"workflow": {
"publicId": "wf_5gt1y8u3i6o0p4a7s2d9f6hj",
"status": "paused",
"...": "every other field, as in Create"
}
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Response fields
workflow.idinteger- Internal numeric id. Address the workflow by publicId.
workflow.publicIdstring- The wf_… id every other endpoint takes.
workflow.namestring- Unique within your team.
workflow.triggerType"contact.created" | "contact.subscribed" | "custom" | "manual"- What enrols contacts.
workflow.triggerConfigobject | null- eventName and/or segmentId — see Triggers.
workflow.status"draft" | "active" | "paused"- Only active workflows enrol contacts.
workflow.createdAt / updatedAtstring- ISO 8601.
Status codes
| Status | Meaning |
|---|---|
| 200 | Paused. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
Post an event#
Enrol a contact in every active custom workflow whose trigger_config.eventName equals event. One call covers however many workflows listen, so adding a workflow for an existing event needs no change in your code. The contact must already exist — this does not create one.
https://api.poststack.dev/workflows/eventsconst { matched } = await poststack.workflows.postEvent({
event: 'order.placed',
email: 'ada@example.com',
});
// A misspelt event name is accepted and enrols nobody — check matched.
if (matched === 0) console.warn('No active workflow listens for order.placed');curl -X POST https://api.poststack.dev/workflows/events \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "event": "order.placed", "email": "ada@example.com" }'{
"event": "order.placed",
"email": "ada@example.com"
}HTTP/1.1 202 Accepted
{
"event": "order.placed",
"matched": 1
}Body parameters
eventstringrequired- 1–64 characters. Matched exactly and case-sensitively.
contact_idstring- The contact’s con_… id. Takes precedence over email.
emailstring- The contact’s email address. One of contact_id or email is required.
Response fields
eventstring- The event you posted.
matchedinteger- Runs started. 0 means no active workflow listens for this name, the contact is outside every entry segment, or already mid-run everywhere.
Status codes
| Status | Meaning |
|---|---|
| 202 | Accepted. Check matched. |
| 400 | Provide either contact_id or email — or another validation error. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Contact not found. |
| 429 | More than 300 requests a minute to this endpoint from one key. Honour Retry-After. |
Trigger a workflow for a contact#
Start a run of an active manual workflow for one contact. Always starts a new run, even if the contact is already mid-run, and ignores any entry segment.
https://api.poststack.dev/workflows/:id/triggerawait poststack.workflows.trigger('wf_5gt1y8u3i6o0p4a7s2d9f6hj', {
contact_id: 'con_8rb2n5m1v7c4x0z6l3k9j2hq',
});curl -X POST https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj/trigger \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "contact_id": "con_8rb2n5m1v7c4x0z6l3k9j2hq" }'{
"contact_id": "con_8rb2n5m1v7c4x0z6l3k9j2hq"
}HTTP/1.1 201 Created
{
"run": {
"id": 512,
"workflowId": 9,
"contactId": 874,
"status": "running",
"currentNodeId": 3301,
"nodesVisited": 0,
"startedAt": "2026-03-23T10:05:00.000Z",
"completedAt": null
}
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Body parameters
contact_idstringrequired- The contact’s con_… id. A numeric id is not accepted.
Response fields
run.idinteger- Run id.
run.workflowId / contactIdinteger- Internal ids of the workflow and contact.
run.status"running" | "completed"- completed at once when the trigger has no outgoing edge.
run.currentNodeIdinteger | null- Internal id of the node the run will execute next.
run.nodesVisitedinteger- Nodes passed so far.
run.startedAt / completedAtstring / string | null- ISO 8601.
Status codes
| Status | Meaning |
|---|---|
| 201 | Run started. |
| 400 | contact_id missing. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found, or Contact not found. |
| 422 | Only manual trigger workflows can be manually triggered — or Workflow must be active to trigger. |
| 429 | More than 10 requests a minute to this endpoint from one key. Honour Retry-After. |
Delete a workflow#
Delete a draft or paused workflow, its graph and its run history.
https://api.poststack.dev/workflows/:idawait poststack.workflows.delete('wf_5gt1y8u3i6o0p4a7s2d9f6hj');curl -X DELETE https://api.poststack.dev/workflows/wf_5gt1y8u3i6o0p4a7s2d9f6hj \
-H "Authorization: Bearer sk_live_..."{
"success": true
}Path parameters
idstringrequired- The workflow's public id (
wf_…).
Response fields
successboolean- Always true on 200.
Status codes
| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended. |
| 404 | Workflow not found in your team. |
| 422 | Cannot delete an active workflow. Pause it first. |
Troubleshooting#
postEvent returns matched: 0
Check, in order: the workflow is active; its triggerConfig.eventName (GET the workflow) is spelled and cased exactly like your event; the contact is in the entry segment, if there is one; the contact is not already mid-run.
Runs fail at a send_email node
Usual causes: the recipient is suppressed; the team has no verified domain and the node has no fromAddress; fromAddress is on a domain that is not verified; the template was deleted.
Saving the graph returns 409
The workflow is active. Pause it, save, and activate again; runs in progress wait while it is paused and continue afterwards.
Contacts stopped mid-journey after an edit
The save removed the node they were held on (or changed its public_id, which counts as removing it). Keep a node's public_id when you edit it, and its runs are kept.
Runs are not moving
Check that the workflow is active: a paused workflow holds its runs until you activate it again.