Skip to content

Workflows

Automate email sequences with workflows. A workflow is a graph: nodes (a trigger, sends, waits, conditions, webhooks) joined by edges that decide what runs next. Create the workflow, save its graph, then activate it. Workflows start automatically from an event, or manually for a specific contact.

POST
/workflows

Create a new workflow. Workflows start in 'draft' status with an empty graph. Save a graph, then activate.

json
{
  "name": "Welcome Sequence",
  "trigger_type": "contact.created",
  "trigger_config": {}
}
GET
/workflows

List all workflows with their status, trigger type, and node/run counts.

json
{
  "data": [
    {
      "id": 1,
      "publicId": "wf_abc123def456ghi789",
      "name": "Welcome Sequence",
      "triggerType": "contact.created",
      "status": "active",
      "stepsCount": 3,
      "runsCount": 128,
      "createdAt": "2026-03-23T10:00:00.000Z",
      "updatedAt": "2026-03-23T10:03:00.000Z"
    },
    {
      "id": 2,
      "publicId": "wf_jkl012mno345pqr678",
      "name": "Re-engagement",
      "triggerType": "manual",
      "status": "paused",
      "stepsCount": 2,
      "runsCount": 0,
      "createdAt": "2026-03-20T10:00:00.000Z",
      "updatedAt": "2026-03-20T10:00:00.000Z"
    }
  ]
}
GET
/workflows/:id

Retrieve a single workflow and its run count. The graph itself lives on GET /workflows/:id/graph.

json
{
  "workflow": {
    "id": 1,
    "publicId": "wf_abc123def456ghi789",
    "name": "Welcome Sequence",
    "triggerType": "contact.created",
    "triggerConfig": {},
    "status": "active",
    "runsCount": 128,
    "createdAt": "2026-03-23T10:00:00.000Z",
    "updatedAt": "2026-03-23T10:03:00.000Z"
  }
}
GET
/workflows/:id/graph

Fetch a workflow's graph — its nodes and the edges between them. Edges reference nodes by public_id.

json
{
  "graph": {
    "nodes": [
      {
        "public_id": "n_trigger",
        "type": "trigger",
        "config": { "triggerType": "contact.created" },
        "canvas_x": 0,
        "canvas_y": 0
      },
      {
        "public_id": "n_welcome",
        "type": "send_email",
        "config": { "templateId": "tpl_welcome_123" },
        "canvas_x": 0,
        "canvas_y": 120
      },
      {
        "public_id": "n_wait",
        "type": "wait",
        "config": { "duration": 3, "unit": "days" },
        "canvas_x": 0,
        "canvas_y": 240
      }
    ],
    "edges": [
      { "from_public_id": "n_trigger", "to_public_id": "n_welcome", "branch": null },
      { "from_public_id": "n_welcome", "to_public_id": "n_wait", "branch": null }
    ]
  }
}
PUT
/workflows/:id/graph

Atomically replace a workflow's graph. Send the complete set of nodes and edges — this is a full replace, not a merge. Returns 409 if the workflow is active: pause it first, because replacing the graph would drop every in-flight run.

json
{
  "nodes": [
    {
      "public_id": "n_trigger",
      "type": "trigger",
      "config": { "triggerType": "contact.created" },
      "canvas_x": 0,
      "canvas_y": 0
    },
    {
      "public_id": "n_welcome",
      "type": "send_email",
      "config": { "templateId": "tpl_welcome_123" },
      "canvas_x": 0,
      "canvas_y": 120
    }
  ],
  "edges": [
    { "from_public_id": "n_trigger", "to_public_id": "n_welcome" }
  ]
}
POST
/workflows/:id/validate

Check the stored graph against the activation rules without saving: exactly one trigger node, no cycles, every node reachable, well-formed condition branches, and a published template on every send_email node.

json
{
  "valid": false,
  "errors": [
    "Workflow must have exactly one trigger node (found none).",
    "Send-email node \"n_welcome\" references template \"tpl_welcome_123\", which is not published."
  ]
}
POST
/workflows/:id/activate

Activate a workflow. New contacts matching the trigger will begin the sequence.

json
{
  "workflow": {
    "id": 1,
    "publicId": "wf_abc123def456ghi789",
    "status": "active"
  }
}
POST
/workflows/:id/pause

Pause an active workflow. Contacts already in the workflow will stop receiving further steps.

json
{
  "workflow": {
    "id": 1,
    "publicId": "wf_abc123def456ghi789",
    "status": "paused"
  }
}
POST
/workflows/:id/trigger

Manually trigger a workflow for a specific contact. Only works for workflows with 'manual' trigger type that are in 'active' status. contact_id is the contact's public id — the raw numeric id is rejected.

json
{
  "contact_id": "con_abc123def456ghi789"
}
POST
/workflows/events

Post an application-defined event. Enrols the contact into every ACTIVE workflow whose trigger is 'custom' and whose event name matches — one call, however many workflows care, so adding a second workflow for an existing event needs no change on your side. Identify the contact by contact_id or by email. Matching is exact and case-sensitive.

json
{
  "event": "order.placed",
  "email": "buyer@acme.io"
}
DELETE
/workflows/:id

Delete a workflow. Any contacts currently enrolled will be removed from the sequence.

json
{
  "success": true
}

Node Types

Every node carries a type, a client-chosen public_id that edges reference, a per-type config, and canvas_x/canvas_y coordinates (layout only — the engine ignores them).

trigger

The entry node — every run starts here, and a graph must have exactly one. Its config.triggerType is written through to the workflow's own trigger, so it is what the event listener matches on. It takes no incoming edges and at most one outgoing edge.

json
{
  "public_id": "n_trigger",
  "type": "trigger",
  "config": { "triggerType": "contact.created" },
  "canvas_x": 0,
  "canvas_y": 0
}

send_email

Send an email using a published template. Template variables are populated from the contact's data and properties. Activation fails if the template is missing or unpublished.

json
{
  "public_id": "n_welcome",
  "type": "send_email",
  "config": { "templateId": "tpl_abc123" },
  "canvas_x": 0,
  "canvas_y": 120
}

wait

Pause the run before continuing. Wait a relative duration (hours/days), or wait until a specific UTC time of day (the next occurrence) or an absolute instant.

json
// Relative delay
{ "public_id": "n_wait", "type": "wait", "config": { "duration": 3, "unit": "days" }, "canvas_x": 0, "canvas_y": 240 }

// Wait until the next 09:00 UTC
{ "public_id": "n_wait", "type": "wait", "config": { "mode": "until", "untilTime": "09:00" }, "canvas_x": 0, "canvas_y": 240 }

// Wait until an absolute instant
{ "public_id": "n_wait", "type": "wait", "config": { "mode": "until", "untilDate": "2026-01-02T09:00:00Z" }, "canvas_x": 0, "canvas_y": 240 }

condition

Branch on a contact field or property. The predicate compares field against value using a comparator (equals, not_equals, contains, not_contains, is_set, is_not_set). Branching lives on the edges, not in the config: a condition node has at most one match edge and at most one no_match edge, both of which must be labelled. If the resolved branch has no edge, the run ends there. (v1's action / skipCount fields are gone.)

json
// "Pro" contacts get the upsell; everyone else goes to the nudge.
{
  "nodes": [
    {
      "public_id": "n_is_pro",
      "type": "condition",
      "config": { "field": "plan", "comparator": "equals", "value": "pro" },
      "canvas_x": 0,
      "canvas_y": 360
    }
  ],
  "edges": [
    { "from_public_id": "n_is_pro", "to_public_id": "n_upsell", "branch": "match" },
    { "from_public_id": "n_is_pro", "to_public_id": "n_nudge", "branch": "no_match" }
  ]
}

webhook

POST the run's workflow and contact context to a URL you control, so a journey can notify an external system. The URL is SSRF-validated (private and reserved addresses are refused); a non-2xx response fails the step and is retried with backoff.

json
{
  "public_id": "n_notify",
  "type": "webhook",
  "config": { "url": "https://hooks.example.com/poststack" },
  "canvas_x": 0,
  "canvas_y": 480
}

Triggers

Workflows are started by a trigger event. Set the trigger_type when creating a workflow:

TriggerDescription
contact.createdAutomatically enrolls new contacts when they are created
contact.subscribedEnrolls contacts when they subscribe to a topic
customEnrolls contacts when your application posts a matching event to POST /workflows/events. The event name is set on the trigger node.
manualOnly enrolls contacts when explicitly triggered via the POST /workflows/:id/trigger endpoint

Building a Workflow

Here's a complete welcome sequence. The graph is saved in one atomic putGraph call — there is no per-step endpoint, and a save replaces the whole graph.

typescript
// 1. Create the workflow (the SDK unwraps the { workflow } envelope)
const workflow = await poststack.workflows.create({
  name: 'Welcome Sequence',
  trigger_type: 'contact.created',
});

// 2. Save the graph: a trigger, a welcome email, a 3-day wait, a follow-up.
//    Edges are what wire the nodes together, so order is explicit.
await poststack.workflows.putGraph(workflow.publicId, {
  nodes: [
    {
      public_id: 'n_trigger',
      type: 'trigger',
      config: { triggerType: 'contact.created' },
      canvas_x: 0,
      canvas_y: 0,
    },
    {
      public_id: 'n_welcome',
      type: 'send_email',
      config: { templateId: 'tpl_welcome' },
      canvas_x: 0,
      canvas_y: 120,
    },
    {
      public_id: 'n_wait',
      type: 'wait',
      config: { duration: 3, unit: 'days' },
      canvas_x: 0,
      canvas_y: 240,
    },
    {
      public_id: 'n_tips',
      type: 'send_email',
      config: { templateId: 'tpl_getting_started' },
      canvas_x: 0,
      canvas_y: 360,
    },
  ],
  edges: [
    { from_public_id: 'n_trigger', to_public_id: 'n_welcome' },
    { from_public_id: 'n_welcome', to_public_id: 'n_wait' },
    { from_public_id: 'n_wait', to_public_id: 'n_tips' },
  ],
});

// 3. Check it before going live (activate runs the same rules and 422s on failure)
const check = await poststack.workflows.validateGraph(workflow.publicId);
if (!check.valid) throw new Error(check.errors.join('\n'));

// 4. Activate. To edit the graph later, pause first — replacing it while
//    active would drop every contact currently mid-journey.
await poststack.workflows.activate(workflow.publicId);