Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## v3.3.0 - Aug 7, 2026

OpenAPI 1.21.6:

- Added `workflows->rerouteNode()` to reroute a node's outgoing connection to another target.
- `workflows->createNode()` now accepts `insert_mode: "after"`, and prefers `to_node_id` over now-deprecated `before_node_id` for `before`.
- Mutation responses for mailing-list changes, node updates, and node deletes now include the latest `workflow`.
- Removed `queuedContactLimitReached` from workflow mutation responses.

## v3.2.0 - Aug 3, 2026

OpenAPI 1.21.1:
Expand Down
58 changes: 53 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [workflows->updateNode()](#workflows-updatenode)
- [workflows->deleteNode()](#workflows-deletenode)
- [workflows->addBranch()](#workflows-addbranch)
- [workflows->rerouteNode()](#workflows-reroutenode)
- [workflows->deleteNodeRecursive()](#workflows-deletenoderecursive)
- [eventPatterns->list()](#eventpatterns-list)
- [eventPatterns->get()](#eventpatterns-get)
Expand Down Expand Up @@ -1764,7 +1765,10 @@ $result = $loops->workflows->getNode(

Create a new default workflow node.

Use `insert_mode: "between"` with `from_node_id` and `to_node_id`, or `insert_mode: "before"` with `before_node_id`.
Use `$insert_mode` to choose placement:
- `between` — insert between `$from_node_id` and `$to_node_id`
- `before` — insert before `$to_node_id`
- `after` — insert after `$from_node_id` (source must have exactly one outgoing connection)

[API Reference](https://loops.so/docs/api-reference/create-workflow-node)

Expand All @@ -1774,11 +1778,11 @@ Use `insert_mode: "between"` with `from_node_id` and `to_node_id`, or `insert_mo
| ------------------------- | ------ | -------- | --------------------------------------------------------------------- |
| `$workflow_id` | string | Yes | The ID of the workflow. |
| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
| `$insert_mode` | string | Yes | `between` or `before`. |
| `$insert_mode` | string | Yes | `between`, `before`, or `after`. |
| `$node_type_name` | string | Yes | One of `AudienceFilter`, `BranchNode`, `ExperimentBranchNode`, `TimerAction`, `SendEmailAction`, `VariantNode`. |
| `$from_node_id` | string | No | Required when `insert_mode` is `between`. |
| `$to_node_id` | string | No | Required when `insert_mode` is `between`. |
| `$before_node_id` | string | No | Required when `insert_mode` is `before`. |
| `$from_node_id` | string | Cond. | Required for `between` and `after`. Not permitted for `before`. |
| `$to_node_id` | string | Cond. | Required for `between`. For `before`, provide this or `$before_node_id` (not both). Not permitted for `after`. |
| `$before_node_id` | string | Cond. | Deprecated. For `before`, provide this or `$to_node_id` (not both). |

#### Example

Expand All @@ -1791,6 +1795,22 @@ $result = $loops->workflows->createNode(
from_node_id: 'clt0u3v5w0232sy31kqvbzs34',
to_node_id: 'clt0u3v5w0232sy31kqvbzs35'
);

$result = $loops->workflows->createNode(
workflow_id: 'cls9t2u4v0210rx20jpuary23',
expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
insert_mode: 'after',
node_type_name: 'TimerAction',
from_node_id: 'clt0u3v5w0232sy31kqvbzs34'
);

$result = $loops->workflows->createNode(
workflow_id: 'cls9t2u4v0210rx20jpuary23',
expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
insert_mode: 'before',
node_type_name: 'TimerAction',
to_node_id: 'clt0u3v5w0232sy31kqvbzs35'
);
```

---
Expand Down Expand Up @@ -1880,6 +1900,34 @@ $result = $loops->workflows->addBranch(

---

### workflows->rerouteNode()

Reroute a source node's outgoing connection to another valid target node. The source node must have exactly one outgoing connection. Branch and experiment branch nodes cannot be rerouted with this endpoint.

[API Reference](https://loops.so/docs/api-reference/reroute-node-connection)

#### Parameters

| Name | Type | Required | Notes |
| ------------------------- | ------ | -------- | --------------------------------------------------------------------- |
| `$workflow_id` | string | Yes | The ID of the workflow. |
| `$node_id` | string | Yes | The ID of the source workflow node. |
| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
| `$new_target_node_id` | string | Yes | The node that should receive the connection. |

#### Example

```php
$result = $loops->workflows->rerouteNode(
workflow_id: 'cls9t2u4v0210rx20jpuary23',
node_id: 'clt0u3v5w0232sy31kqvbzs34',
expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
new_target_node_id: 'cln3c5d7e9f1g3h5i7j9k1l3'
);
```

---

### workflows->deleteNodeRecursive()

Delete a node and its downstream subtree.
Expand Down
52 changes: 50 additions & 2 deletions src/Workflows.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,42 @@ public function createNode(
];

if ($insert_mode === 'between') {
if ($from_node_id === null || $to_node_id === null) {
throw new \InvalidArgumentException(message: 'from_node_id and to_node_id are required when insert_mode is "between".');
}
if ($before_node_id !== null) {
throw new \InvalidArgumentException(message: 'before_node_id is not permitted when insert_mode is "between".');
}
$payload['fromNodeId'] = $from_node_id;
$payload['toNodeId'] = $to_node_id;
} elseif ($insert_mode === 'before') {
$payload['beforeNodeId'] = $before_node_id;
if ($from_node_id !== null) {
throw new \InvalidArgumentException(message: 'from_node_id is not permitted when insert_mode is "before".');
}
if ($to_node_id !== null && $before_node_id !== null) {
throw new \InvalidArgumentException(message: 'Provide either to_node_id or before_node_id when insert_mode is "before", not both.');
}
if ($to_node_id === null && $before_node_id === null) {
throw new \InvalidArgumentException(message: 'to_node_id or before_node_id is required when insert_mode is "before".');
}
if ($to_node_id !== null) {
$payload['toNodeId'] = $to_node_id;
} else {
$payload['beforeNodeId'] = $before_node_id;
}
} elseif ($insert_mode === 'after') {
if ($from_node_id === null) {
throw new \InvalidArgumentException(message: 'from_node_id is required when insert_mode is "after".');
}
if ($to_node_id !== null) {
throw new \InvalidArgumentException(message: 'to_node_id is not permitted when insert_mode is "after".');
}
if ($before_node_id !== null) {
throw new \InvalidArgumentException(message: 'before_node_id is not permitted when insert_mode is "after".');
}
$payload['fromNodeId'] = $from_node_id;
} else {
throw new \InvalidArgumentException(message: 'insert_mode must be "between" or "before".');
throw new \InvalidArgumentException(message: 'insert_mode must be "between", "before", or "after".');
}

return $this->client->query(method: 'POST', endpoint: 'v1/workflows/' . $workflow_id . '/nodes', options: [
Expand Down Expand Up @@ -176,6 +206,24 @@ public function addBranch(
);
}

public function rerouteNode(
string $workflow_id,
string $node_id,
?string $expected_revision_id,
string $new_target_node_id
): mixed {
return $this->client->query(
method: 'POST',
endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id . '/reroute',
options: [
'json' => [
'expectedRevisionId' => $expected_revision_id,
'newTargetNodeId' => $new_target_node_id,
]
]
);
}

public function deleteNodeRecursive(
string $workflow_id,
string $node_id,
Expand Down
112 changes: 111 additions & 1 deletion tests/WorkflowsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,80 @@ public function testCreateNodeBetween(): void
$this->assertEquals('node_3', $result['node']['id']);
}

public function testCreateNodeBeforeWithToNodeId(): void
{
$workflowId = 'wf_123';

$this->mockHttpClient
->expects($this->once())
->method('post')
->with(
'v1/workflows/' . $workflowId . '/nodes',
$this->callback(function ($options) {
return $options['json'] === [
'expectedRevisionId' => 'rev_123',
'insertMode' => 'before',
'nodeTypeName' => 'TimerAction',
'toNodeId' => 'node_2',
];
})
)
->willReturn(new Response(
status: 200,
body: json_encode([
'node' => ['id' => 'node_3', 'typeName' => 'TimerAction'],
'workflow' => ['id' => $workflowId],
])
));

$result = $this->client->workflows->createNode(
workflow_id: $workflowId,
expected_revision_id: 'rev_123',
insert_mode: 'before',
node_type_name: 'TimerAction',
to_node_id: 'node_2'
);

$this->assertEquals('node_3', $result['node']['id']);
}

public function testCreateNodeAfter(): void
{
$workflowId = 'wf_123';

$this->mockHttpClient
->expects($this->once())
->method('post')
->with(
'v1/workflows/' . $workflowId . '/nodes',
$this->callback(function ($options) {
return $options['json'] === [
'expectedRevisionId' => 'rev_123',
'insertMode' => 'after',
'nodeTypeName' => 'TimerAction',
'fromNodeId' => 'node_1',
];
})
)
->willReturn(new Response(
status: 200,
body: json_encode([
'node' => ['id' => 'node_3', 'typeName' => 'TimerAction'],
'workflow' => ['id' => $workflowId],
])
));

$result = $this->client->workflows->createNode(
workflow_id: $workflowId,
expected_revision_id: 'rev_123',
insert_mode: 'after',
node_type_name: 'TimerAction',
from_node_id: 'node_1'
);

$this->assertEquals('node_3', $result['node']['id']);
}

public function testUpdateNode(): void
{
$workflowId = 'wf_123';
Expand Down Expand Up @@ -314,6 +388,43 @@ public function testAddBranch(): void
$this->assertEquals('node_child', $result['node']['id']);
}

public function testRerouteNode(): void
{
$workflowId = 'wf_123';
$nodeId = 'node_source';

$this->mockHttpClient
->expects($this->once())
->method('post')
->with(
'v1/workflows/' . $workflowId . '/nodes/' . $nodeId . '/reroute',
$this->callback(function ($options) {
return $options['json'] === [
'expectedRevisionId' => 'rev_123',
'newTargetNodeId' => 'node_target',
];
})
)
->willReturn(new Response(
status: 200,
body: json_encode([
'id' => $nodeId,
'nextNodeIds' => ['node_target'],
'workflowRevisionId' => 'rev_456',
'workflow' => ['id' => $workflowId],
])
));

$result = $this->client->workflows->rerouteNode(
workflow_id: $workflowId,
node_id: $nodeId,
expected_revision_id: 'rev_123',
new_target_node_id: 'node_target'
);

$this->assertEquals(['node_target'], $result['nextNodeIds']);
}

public function testDeleteNodeRecursive(): void
{
$workflowId = 'wf_123';
Expand All @@ -337,7 +448,6 @@ public function testDeleteNodeRecursive(): void
'status' => 'dryRun',
'nodeIds' => [$nodeId, 'node_789'],
'queuedContactCount' => 0,
'queuedContactLimitReached' => false,
])
));

Expand Down
Loading