diff --git a/README.md b/README.md
index 6ddca9a..a549ad2 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,7 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [updateWorkflowNode()](#updateworkflownode)
- [deleteWorkflowNode()](#deleteworkflownode)
- [addWorkflowBranch()](#addworkflowbranch)
+- [rerouteWorkflowNode()](#rerouteworkflownode)
- [deleteWorkflowNodesRecursive()](#deleteworkflownodesrecursive)
- [createUpload()](#createupload)
- [completeUpload()](#completeupload)
@@ -1701,7 +1702,7 @@ const resp = await loops.getEmailMessage("clm9x3o5q002yl70a8b3c4d5e");
"fromEmail": "hello",
"replyToEmail": "",
"emailFormat": "styled",
- "lmx": "...",
+ "lmx": "
...
...",
"contentRevisionId": "clv8g2x4z012yl70n5o6p7q8r",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
@@ -1745,7 +1746,7 @@ const resp = await loops.updateEmailMessage("clm9x3o5q002yl70a8b3c4d5e", {
previewText: "Preview text",
fromName: "Loops",
fromEmail: "hello",
- lmx: "...",
+ lmx: "...
...",
});
```
@@ -1761,7 +1762,7 @@ const resp = await loops.updateEmailMessage("clm9x3o5q002yl70a8b3c4d5e", {
"fromEmail": "hello",
"replyToEmail": "",
"emailFormat": "styled",
- "lmx": "...",
+ "lmx": "...
...",
"contentRevisionId": "clv8g2x4z013yl70s9t0u1v2w",
"updatedAt": "2025-01-02T00:00:00.000Z",
"warnings": [
@@ -2650,7 +2651,15 @@ const resp = await loops.changeWorkflowMailingList("cls5d9u1w009yl70c7d8e9f0g",
"mailingListId": "clm1a2b3c004yl70d5e6f7g8h",
"workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
"queuedContactCount": 0,
- "queuedContactLimitReached": false
+ "workflow": {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": "clm1a2b3c004yl70d5e6f7g8h",
+ "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {}
+ }
}
```
@@ -2658,7 +2667,7 @@ const resp = await loops.changeWorkflowMailingList("cls5d9u1w009yl70c7d8e9f0g",
### createWorkflowNode()
-Create a new default workflow node. Use `insertMode: "between"` or `insertMode: "before"`, then configure with [`updateWorkflowNode()`](#updateworkflownode).
+Create a new default workflow node. Use `insertMode: "between"`, `insertMode: "before"` (with `toNodeId`), or `insertMode: "after"`, then configure with [`updateWorkflowNode()`](#updateworkflownode).
[API Reference](https://loops.so/docs/api-reference/create-workflow-node)
#### Parameters
@@ -2667,11 +2676,11 @@ Create a new default workflow node. Use `insertMode: "between"` or `insertMode:
| -------------------- | ------ | -------- | --------------------------------------------------------------------- |
| `workflowId` | string | Yes | The ID of the workflow. |
| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. |
-| `insertMode` | string | Yes | `"between"` or `"before"`. |
+| `insertMode` | string | Yes | `"between"`, `"before"`, or `"after"`. |
| `nodeTypeName` | string | Yes | Node type to create (for example `TimerAction`, `AudienceFilter`). |
-| `fromNodeId` | string | Cond. | Required when `insertMode` is `"between"`. |
-| `toNodeId` | string | Cond. | Required when `insertMode` is `"between"`. |
-| `beforeNodeId` | string | Cond. | Required when `insertMode` is `"before"`. |
+| `fromNodeId` | string | Cond. | Required when `insertMode` is `"between"` or `"after"`. For `"after"`, the source must have exactly one outgoing node. |
+| `toNodeId` | string | Cond. | Required when `insertMode` is `"between"` or `"before"`. |
+| `beforeNodeId` | string | Cond. | Deprecated. Use `toNodeId` with `insertMode: "before"`. |
#### Example
@@ -2808,7 +2817,16 @@ const resp = await loops.updateWorkflowNode(
],
"amount": 1,
"unit": "h",
- "workflowRevisionId": "clrev0w0r1k2f3l4o5w6"
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "workflow": {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {}
+ }
}
```
@@ -2849,7 +2867,15 @@ const resp = await loops.deleteWorkflowNode(
],
"workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
"queuedContactCount": 0,
- "queuedContactLimitReached": false
+ "workflow": {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {}
+ }
}
```
@@ -2917,6 +2943,58 @@ const resp = await loops.addWorkflowBranch(
---
+### rerouteWorkflowNode()
+
+Reroute a source node's single outgoing connection to another valid target node. The source must have exactly one outgoing connection (branch/experiment nodes are not supported).
+[API Reference](https://loops.so/docs/api-reference/reroute-node-connection)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------ | -------- | ------------------------------------------------------------------ |
+| `workflowId` | string | Yes | The ID of the workflow. |
+| `nodeId` | string | Yes | The ID of the source workflow node. |
+| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. |
+| `newTargetNodeId` | string | Yes | The node that should receive the connection. |
+
+#### Example
+
+```javascript
+const resp = await loops.rerouteWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "clt6e0v2x010yl70h1i2j3k4l",
+ {
+ expectedRevisionId: "rev_1",
+ newTargetNodeId: "clu7f1w3y011yl70m5n6o7p8q",
+ }
+);
+```
+
+#### Response
+
+```json
+{
+ "id": "cln8p0q2r4s6t8u0v2w4x6z8",
+ "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "typeName": "SignupTrigger",
+ "nextNodeIds": [
+ "clu7f1w3y011yl70m5n6o7p8q"
+ ],
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "workflow": {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "cln8p0q2r4s6t8u0v2w4x6z8",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {}
+ }
+}
+```
+
+---
+
### deleteWorkflowNodesRecursive()
Delete a node and its downstream subtree. If contacts are queued, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"`.
@@ -2953,7 +3031,15 @@ const resp = await loops.deleteWorkflowNodesRecursive(
],
"workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
"queuedContactCount": 0,
- "queuedContactLimitReached": false
+ "workflow": {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {}
+ }
}
```
@@ -3023,6 +3109,12 @@ const resp = await loops.completeUpload("clu7f1w3y011yl70m5n6o7p8q");
## Version history
+- `v7.2.0` (Aug 7, 2026)
+ - Aligned with OpenAPI 1.21.6.
+ - Added [`rerouteWorkflowNode()`](#rerouteworkflownode).
+ - Added `insertMode: "after"` for [`createWorkflowNode()`](#createworkflownode); `before` mode now prefers `toNodeId` (`beforeNodeId` remains as a deprecated alias).
+ - Mutation responses for mailing-list changes, node updates, and node deletes now include the latest `workflow`.
+ - Removed `queuedContactLimitReached` from workflow mutation responses.
- `v7.1.0` (Aug 3, 2026)
- Added event patterns ([`listEventPatterns()`](#listeventpatterns), [`getEventPattern()`](#geteventpattern), [`getEventPatternByName()`](#geteventpatternbyname)).
- Expanded workflows with create/update/mutate APIs ([`createWorkflow()`](#createworkflow), [`updateWorkflow()`](#updateworkflow), [`changeWorkflowMailingList()`](#changeworkflowmailinglist), [`createWorkflowNode()`](#createworkflownode), [`updateWorkflowNode()`](#updateworkflownode), [`deleteWorkflowNode()`](#deleteworkflownode), [`addWorkflowBranch()`](#addworkflowbranch), [`deleteWorkflowNodesRecursive()`](#deleteworkflownodesrecursive)).
diff --git a/package-lock.json b/package-lock.json
index 5373ee6..12eadec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loops",
- "version": "7.1.0",
+ "version": "7.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loops",
- "version": "7.1.0",
+ "version": "7.2.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.12",
diff --git a/package.json b/package.json
index 390cae5..1adaa1f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "loops",
- "version": "7.1.0",
+ "version": "7.2.0",
"author": "Dan Rowden ",
"license": "MIT",
"main": "./dist/index.cjs",
diff --git a/src/__tests__/LoopsClient.test.ts b/src/__tests__/LoopsClient.test.ts
index c4feba0..dbaf141 100644
--- a/src/__tests__/LoopsClient.test.ts
+++ b/src/__tests__/LoopsClient.test.ts
@@ -1890,7 +1890,14 @@ describe("LoopsClient", () => {
mailingListId: "clm1",
workflowRevisionId: "rev_2",
queuedContactCount: 0,
- queuedContactLimitReached: false,
+ workflow: {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ mailingListId: "clm1",
+ rootNodeId: "root",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ },
};
global.fetch = jest.fn().mockResolvedValue({
@@ -1975,6 +1982,108 @@ describe("LoopsClient", () => {
})
);
});
+
+ it("should create a workflow node after a node", async () => {
+ const mockResponse = {
+ node: {
+ id: "new_node",
+ workflowId: "cls5d9u1w009yl70c7d8e9f0g",
+ typeName: "TimerAction",
+ nextNodeIds: ["next"],
+ workflowRevisionId: "rev_2",
+ },
+ workflow: {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ mailingListId: null,
+ rootNodeId: "from",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ },
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.createWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ {
+ expectedRevisionId: "rev_1",
+ insertMode: "after",
+ nodeTypeName: "TimerAction",
+ fromNodeId: "from",
+ }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes"
+ ),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ expectedRevisionId: "rev_1",
+ insertMode: "after",
+ nodeTypeName: "TimerAction",
+ fromNodeId: "from",
+ }),
+ })
+ );
+ });
+
+ it("should create a workflow node before a node using toNodeId", async () => {
+ const mockResponse = {
+ node: {
+ id: "new_node",
+ workflowId: "cls5d9u1w009yl70c7d8e9f0g",
+ typeName: "AudienceFilter",
+ nextNodeIds: ["to"],
+ workflowRevisionId: "rev_2",
+ },
+ workflow: {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ mailingListId: null,
+ rootNodeId: "root",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ },
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.createWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ {
+ expectedRevisionId: "rev_1",
+ insertMode: "before",
+ nodeTypeName: "AudienceFilter",
+ toNodeId: "to",
+ }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes"
+ ),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ expectedRevisionId: "rev_1",
+ insertMode: "before",
+ nodeTypeName: "AudienceFilter",
+ toNodeId: "to",
+ }),
+ })
+ );
+ });
});
describe("updateWorkflowNode", () => {
@@ -1987,6 +2096,14 @@ describe("LoopsClient", () => {
amount: 1,
unit: "h",
workflowRevisionId: "rev_2",
+ workflow: {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ mailingListId: null,
+ rootNodeId: "root",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ },
};
global.fetch = jest.fn().mockResolvedValue({
@@ -2026,7 +2143,14 @@ describe("LoopsClient", () => {
nodeIds: ["node1"],
workflowRevisionId: "rev_2",
queuedContactCount: 0,
- queuedContactLimitReached: false,
+ workflow: {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ mailingListId: null,
+ rootNodeId: "root",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ },
};
global.fetch = jest.fn().mockResolvedValue({
@@ -2097,6 +2221,54 @@ describe("LoopsClient", () => {
});
});
+ describe("rerouteWorkflowNode", () => {
+ it("should reroute a workflow node connection", async () => {
+ const mockResponse = {
+ id: "node1",
+ workflowId: "cls5d9u1w009yl70c7d8e9f0g",
+ typeName: "SignupTrigger",
+ nextNodeIds: ["target"],
+ workflowRevisionId: "rev_2",
+ workflow: {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ mailingListId: null,
+ rootNodeId: "node1",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ },
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.rerouteWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "node1",
+ {
+ expectedRevisionId: "rev_1",
+ newTargetNodeId: "target",
+ }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1/reroute"
+ ),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ expectedRevisionId: "rev_1",
+ newTargetNodeId: "target",
+ }),
+ })
+ );
+ });
+ });
+
describe("deleteWorkflowNodesRecursive", () => {
it("should recursively delete workflow nodes", async () => {
const mockResponse = {
@@ -2104,7 +2276,14 @@ describe("LoopsClient", () => {
nodeIds: ["node1", "node2"],
workflowRevisionId: "rev_2",
queuedContactCount: 0,
- queuedContactLimitReached: false,
+ workflow: {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ mailingListId: null,
+ rootNodeId: "root",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ },
};
global.fetch = jest.fn().mockResolvedValue({
diff --git a/src/index.ts b/src/index.ts
index b4355a4..6144106 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -459,7 +459,20 @@ type CreateWorkflowNodeParams =
expectedRevisionId: WorkflowExpectedRevisionId;
insertMode: "before";
nodeTypeName: CreateWorkflowNodeTypeName;
+ toNodeId: string;
+ }
+ | {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ insertMode: "before";
+ nodeTypeName: CreateWorkflowNodeTypeName;
+ /** @deprecated Use `toNodeId` instead. */
beforeNodeId: string;
+ }
+ | {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ insertMode: "after";
+ nodeTypeName: CreateWorkflowNodeTypeName;
+ fromNodeId: string;
};
type CreatedWorkflowNode = WorkflowNodeWithRevision & {
@@ -486,7 +499,6 @@ interface WorkflowMailingListPreview {
status: "dryRun" | "queuedContactsFound";
mailingListId: string | null;
queuedContactCount: number;
- queuedContactLimitReached: boolean;
}
interface WorkflowMailingListUpdatedResponse {
@@ -494,7 +506,7 @@ interface WorkflowMailingListUpdatedResponse {
mailingListId: string | null;
workflowRevisionId: string;
queuedContactCount: number;
- queuedContactLimitReached: boolean;
+ workflow: SimplifiedWorkflow;
}
type ChangeWorkflowMailingListResponse =
@@ -505,7 +517,6 @@ interface WorkflowQueuedContactDeletePreview {
status: "dryRun" | "queuedContactsFound";
nodeIds: string[];
queuedContactCount: number;
- queuedContactLimitReached: boolean;
}
interface WorkflowDeletedResponse {
@@ -513,9 +524,17 @@ interface WorkflowDeletedResponse {
nodeIds: string[];
workflowRevisionId: string;
queuedContactCount: number;
- queuedContactLimitReached: boolean;
+ workflow: SimplifiedWorkflow;
}
+type UpdateWorkflowNodeResponse = WorkflowNodeWithRevision & {
+ workflow: SimplifiedWorkflow;
+};
+
+type RerouteNodeConnectionResponse = WorkflowNodeWithRevision & {
+ workflow: SimplifiedWorkflow;
+};
+
type DeleteWorkflowNodeResponse =
| WorkflowQueuedContactDeletePreview
| WorkflowDeletedResponse;
@@ -2352,8 +2371,9 @@ class LoopsClient {
* Create a new default workflow node and return it with the latest workflow.
*
* Use `insertMode: "between"` to place the node between an existing connection,
- * or `insertMode: "before"` to insert before a node. Configure the node after
- * creation with `updateWorkflowNode()`.
+ * `insertMode: "before"` to insert before a node (`toNodeId`), or
+ * `insertMode: "after"` to insert after a node that has exactly one outgoing
+ * connection. Configure the node after creation with `updateWorkflowNode()`.
*
* @param {string} workflowId The ID of the workflow.
* @param {Object} params Create parameters including `insertMode` and revision.
@@ -2406,7 +2426,7 @@ class LoopsClient {
*
* @see https://loops.so/docs/api-reference/update-workflow-node
*
- * @returns {Object} Updated workflow node (JSON)
+ * @returns {Object} Updated workflow node and latest workflow (JSON)
*/
async updateWorkflowNode(
workflowId: string,
@@ -2418,7 +2438,7 @@ class LoopsClient {
expectedRevisionId: WorkflowExpectedRevisionId;
payload: UpdateWorkflowNodePayload;
}
- ): Promise {
+ ): Promise {
return this._makeQuery({
path: `v1/workflows/${workflowId}/nodes/${nodeId}`,
method: "POST",
@@ -2501,6 +2521,40 @@ class LoopsClient {
});
}
+ /**
+ * Reroute a source node's single outgoing connection to another target node.
+ *
+ * The source node must have exactly one outgoing connection. Branch and
+ * experiment branch nodes cannot be rerouted with this method.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @param {string} nodeId The ID of the source workflow node.
+ * @param {Object} params
+ * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet.
+ * @param {string} params.newTargetNodeId The node that should receive the connection.
+ *
+ * @see https://loops.so/docs/api-reference/reroute-node-connection
+ *
+ * @returns {Object} Updated source node and latest workflow (JSON)
+ */
+ async rerouteWorkflowNode(
+ workflowId: string,
+ nodeId: string,
+ {
+ expectedRevisionId,
+ newTargetNodeId,
+ }: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ newTargetNodeId: string;
+ }
+ ): Promise {
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}/nodes/${nodeId}/reroute`,
+ method: "POST",
+ payload: { expectedRevisionId, newTargetNodeId },
+ });
+ }
+
/**
* Delete a node and its downstream subtree.
*
@@ -2875,6 +2929,8 @@ export {
WorkflowQueuedContactDeletePreview,
WorkflowDeletedResponse,
DeleteWorkflowNodeResponse,
+ UpdateWorkflowNodeResponse,
+ RerouteNodeConnectionResponse,
WorkflowContactPropertyComparisonOperator,
WorkflowContactPropertyComparison,
WorkflowContactPropertyQuery,