Skip to content

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#

  1. Create the workflow

    POST /workflows with a name and a trigger type. It starts as draft with one node already in its graph: a trigger node.

  2. Save its graph

    PUT /workflows/:id/graph with every node and edge. You always send the whole graph; there is no per-node endpoint.

  3. Activate it

    POST /workflows/:id/activate checks the graph (the same rules POST /workflows/:id/validate reports) 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 a wait is 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 draft or paused (409 otherwise). Nodes are matched by public_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 is stopped when you save.
  • Deleting needs the workflow not to be active, and deletes its run history with it.

Triggers#

trigger_typeEnrols a contact when…
contact.createdA contact is created with POST /contacts, or a signup form submission creates a new one. Contacts added by CSV import do not enrol.
contact.subscribedA signup form submission subscribes the contact to a topic — including an existing contact re-subscribing.
customYour application posts an event to POST /workflows/events whose name equals the workflow's trigger_config.eventName (exact, case-sensitive).
manualYou call POST /workflows/:id/trigger for a specific contact.

Trigger settings

trigger_config.eventNamestring
custom only. The event this workflow listens for. A custom workflow without one matches no event.
trigger_config.segmentIdstring
Optional entry filter (seg_…) for contact.created, contact.subscribed and custom: only contacts in this segment enrol. If the segment is deleted, nobody enrols. Manual triggers ignore it.

Where trigger settings live

Saving the graph copies the trigger node's config.triggerType and config.segmentId onto the workflow, replacing its trigger_config — for contact.created, contact.subscribed and manual. So for those, put segmentId on the trigger node. A custom trigger node is not copied: set eventName (and segmentId) in the workflow's trigger_config when you create it, or later with PATCH /workflows/:id.

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.com or Acme <hello@yourdomain.com>. Defaults to noreply@ your team's oldest verified domain; with no verified domain the step fails.
fromNamestring
Display name, used only when fromAddress is 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

ConfigWaits
{ "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

fieldstringrequired
email, 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_set means non-empty. An unknown comparator always takes no_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:

json
{
  "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 trigger node, 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 match and one no_match;
  • every send_email node 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.

typescript
// 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).

POSThttps://api.poststack.dev/workflows
Request
// The SDK unwraps the { workflow } envelope.
const workflow = await poststack.workflows.create({
  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/or segmentId.

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

StatusMeaning
201Workflow created.
400Body failed validation.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
409A workflow with that name already exists.
429More 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.

GEThttps://api.poststack.dev/workflows
Request
// Returns the array directly.
const workflows = await poststack.workflows.list();
{
  "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

StatusMeaning
200The workflows.
401Missing or invalid API key.
403insufficient_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.

GEThttps://api.poststack.dev/workflows/:id
Request
const workflow = await poststack.workflows.get('wf_5gt1y8u3i6o0p4a7s2d9f6hj');
{
  "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

StatusMeaning
200The workflow.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow 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.

PATCHhttps://api.poststack.dev/workflows/:id
Request
const workflow = await poststack.workflows.update('wf_5gt1y8u3i6o0p4a7s2d9f6hj', {
  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

StatusMeaning
200Updated (or unchanged, if the body was empty).
400Body failed validation.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow not found in your team.
409A workflow with that name already exists.
422Pause the workflow before changing its trigger type.

Get the graph#

The workflow's nodes (ordered top to bottom by canvas_y) and edges.

GEThttps://api.poststack.dev/workflows/:id/graph
Request
const graph = await poststack.workflows.getGraph('wf_5gt1y8u3i6o0p4a7s2d9f6hj');
{
  "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

StatusMeaning
200The graph.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow 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.

PUThttps://api.poststack.dev/workflows/:id/graph
Request
const 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' }],
});
{
  "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

StatusMeaning
200Saved.
400Body failed validation (unknown node type, public_id over 64 characters, too many nodes…).
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow not found in your team.
409The workflow is active. Pause it first — pausing holds its runs, it does not end them.
422Duplicate 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.
429More 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.

POSThttps://api.poststack.dev/workflows/:id/validate
Request
const result = await poststack.workflows.validateGraph('wf_5gt1y8u3i6o0p4a7s2d9f6hj');
if (!result.valid) console.error(result.errors);
{
  "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

StatusMeaning
200The result (valid or not).
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow 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.

POSThttps://api.poststack.dev/workflows/:id/activate
Request
const workflow = await poststack.workflows.activate('wf_5gt1y8u3i6o0p4a7s2d9f6hj');
{
  "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

StatusMeaning
200Active.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow not found in your team.
422Workflow graph is invalid: followed by every validation error.
429More 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.

POSThttps://api.poststack.dev/workflows/:id/pause
Request
const workflow = await poststack.workflows.pause('wf_5gt1y8u3i6o0p4a7s2d9f6hj');
{
  "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

StatusMeaning
200Paused.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow not found in your team.
429More 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.

POSThttps://api.poststack.dev/workflows/events
Request
const { 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');
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

StatusMeaning
202Accepted. Check matched.
400Provide either contact_id or email — or another validation error.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Contact not found.
429More 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.

POSThttps://api.poststack.dev/workflows/:id/trigger
Request
await poststack.workflows.trigger('wf_5gt1y8u3i6o0p4a7s2d9f6hj', {
  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

StatusMeaning
201Run started.
400contact_id missing.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow not found, or Contact not found.
422Only manual trigger workflows can be manually triggered — or Workflow must be active to trigger.
429More 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.

DELETEhttps://api.poststack.dev/workflows/:id
Request
await poststack.workflows.delete('wf_5gt1y8u3i6o0p4a7s2d9f6hj');
{
  "success": true
}

Path parameters

idstringrequired
The workflow's public id (wf_…).

Response fields

successboolean
Always true on 200.

Status codes

StatusMeaning
200Deleted.
401Missing or invalid API key.
403insufficient_scope — every workflow endpoint needs workflows:manage, which a sending_access key does not have. Also returned when the team is suspended.
404Workflow not found in your team.
422Cannot 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.

Next steps

Was this page helpful?

Related