diff --git a/README.md b/README.md
index ca267c5..6ddca9a 100644
--- a/README.md
+++ b/README.md
@@ -116,10 +116,15 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [listDedicatedSendingIps()](#listdedicatedsendingips)
- [listAudienceSegments()](#listaudiencesegments)
- [getAudienceSegment()](#getaudiencesegment)
+- [createAudienceSegment()](#createaudiencesegment)
- [listThemes()](#listthemes)
- [getTheme()](#gettheme)
+- [createTheme()](#createtheme)
+- [updateTheme()](#updatetheme)
- [listComponents()](#listcomponents)
- [getComponent()](#getcomponent)
+- [createComponent()](#createcomponent)
+- [updateComponent()](#updatecomponent)
- [listCampaigns()](#listcampaigns)
- [createCampaign()](#createcampaign)
- [getCampaign()](#getcampaign)
@@ -131,9 +136,21 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [getEmailMessage()](#getemailmessage)
- [updateEmailMessage()](#updateemailmessage)
- [sendEmailMessagePreview()](#sendemailmessagepreview)
+- [runEmailMessageGuardian()](#runemailmessageguardian)
+- [listEventPatterns()](#listeventpatterns)
+- [getEventPattern()](#geteventpattern)
+- [getEventPatternByName()](#geteventpatternbyname)
- [listWorkflows()](#listworkflows)
+- [createWorkflow()](#createworkflow)
- [getWorkflow()](#getworkflow)
+- [updateWorkflow()](#updateworkflow)
+- [changeWorkflowMailingList()](#changeworkflowmailinglist)
+- [createWorkflowNode()](#createworkflownode)
- [getWorkflowNode()](#getworkflownode)
+- [updateWorkflowNode()](#updateworkflownode)
+- [deleteWorkflowNode()](#deleteworkflownode)
+- [addWorkflowBranch()](#addworkflowbranch)
+- [deleteWorkflowNodesRecursive()](#deleteworkflownodesrecursive)
- [createUpload()](#createupload)
- [completeUpload()](#completeupload)
@@ -937,6 +954,23 @@ Retrieve a single transactional email by ID.
const resp = await loops.getTransactionalEmail("clfn0k1yg001imo0fdeqg30i8");
```
+#### Response
+
+```json
+{
+ "id": "cll42l54f20i1la0lfooe3z12",
+ "name": "Sign up confirmation",
+ "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5",
+ "publishedEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5",
+ "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "dataVariables": [
+ "confirmationUrl"
+ ]
+}
+```
+
---
### createTransactionalEmail()
@@ -958,6 +992,22 @@ Create a new transactional email. An empty draft email message is created automa
const resp = await loops.createTransactionalEmail({ name: "Welcome email" });
```
+#### Response
+
+```json
+{
+ "id": "cll42l54f20i1la0lfooe3z12",
+ "name": "Welcome email",
+ "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5",
+ "draftEmailMessageContentRevisionId": "clrev1s10n2i3d4e5f6g7h8",
+ "publishedEmailMessageId": null,
+ "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "dataVariables": []
+}
+```
+
---
### updateTransactionalEmail()
@@ -986,13 +1036,30 @@ const resp = await loops.updateTransactionalEmail("clfn0k1yg001imo0fdeqg30i8", {
});
```
+#### Response
+
+```json
+{
+ "id": "cll42l54f20i1la0lfooe3z12",
+ "name": "Updated name",
+ "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5",
+ "publishedEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5",
+ "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "dataVariables": [
+ "confirmationUrl"
+ ]
+}
+```
+
---
### ensureTransactionalEmailDraft()
Ensure a transactional email has a draft email message. Use [`updateEmailMessage()`](#updateemailmessage) to edit the draft content.
-[API Reference](https://loops.so/docs/api-reference/ensure-transactional-email-draft)
+[API Reference](https://loops.so/docs/api-reference/ensure-transactional-draft)
#### Parameters
@@ -1006,6 +1073,22 @@ Ensure a transactional email has a draft email message. Use [`updateEmailMessage
const resp = await loops.ensureTransactionalEmailDraft("clfn0k1yg001imo0fdeqg30i8");
```
+#### Response
+
+```json
+{
+ "id": "cll42l54f20i1la0lfooe3z12",
+ "name": "Sign up confirmation",
+ "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5",
+ "draftEmailMessageContentRevisionId": "clrev1s10n2i3d4e5f6g7h8",
+ "publishedEmailMessageId": null,
+ "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "dataVariables": []
+}
+```
+
---
### publishTransactionalEmail()
@@ -1026,6 +1109,21 @@ Publish a transactional email's current draft.
const resp = await loops.publishTransactionalEmail("clfn0k1yg001imo0fdeqg30i8");
```
+#### Response
+
+```json
+{
+ "id": "cll42l54f20i1la0lfooe3z12",
+ "name": "Sign up confirmation",
+ "draftEmailMessageId": null,
+ "publishedEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5",
+ "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "dataVariables": []
+}
+```
+
---
### listDedicatedSendingIps()
@@ -1144,6 +1242,83 @@ Error handling is done through the `APIError` class, which provides `statusCode`
---
+### createTheme()
+
+Create a new email theme.
+[API Reference](https://loops.so/docs/api-reference/create-theme)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------- | ------ | -------- | ---------------------------------- |
+| `name` | string | Yes | The theme name. |
+| `styles` | object | No | Style attributes for the theme. |
+
+#### Example
+
+```javascript
+const resp = await loops.createTheme({
+ name: "Brand",
+ styles: { backgroundColor: "#111111" },
+});
+```
+
+#### Response
+
+```json
+{
+ "id": "clt3u5v7w9x1y3z5a7b9c1d3",
+ "name": "Brand",
+ "styles": {
+ "backgroundColor": "#111111"
+ },
+ "isDefault": false,
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+}
+```
+
+---
+
+### updateTheme()
+
+Update a theme's name and/or styles. When `styles` change, the update cascades to emails using the theme; `affectedEmailCount` reports how many were affected.
+[API Reference](https://loops.so/docs/api-reference/update-theme)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------- | ------ | -------- | ------------------------------- |
+| `themeId` | string | Yes | The ID of the theme. |
+| `name` | string | No | The theme name. |
+| `styles` | object | No | Style attributes for the theme. |
+
+#### Example
+
+```javascript
+const resp = await loops.updateTheme("clo1z5q7s004yl70y3z4a5b6c", {
+ name: "Brand Updated",
+});
+```
+
+#### Response
+
+```json
+{
+ "id": "clt3u5v7w9x1y3z5a7b9c1d3",
+ "name": "Brand Updated",
+ "styles": {
+ "backgroundColor": "#111111"
+ },
+ "isDefault": false,
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "affectedEmailCount": 3
+}
+```
+
+---
+
### listComponents()
Retrieve a paginated list of email components.
@@ -1221,6 +1396,73 @@ Error handling is done through the `APIError` class, which provides `statusCode`
---
+### createComponent()
+
+Create a new email component.
+[API Reference](https://loops.so/docs/api-reference/create-component)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------ | ------ | -------- | ------------------------------------- |
+| `name` | string | Yes | The component name. |
+| `lmx` | string | Yes | The component body as an LMX string. |
+
+#### Example
+
+```javascript
+const resp = await loops.createComponent({
+ name: "Footer",
+ lmx: "",
+});
+```
+
+#### Response
+
+```json
+{
+ "id": "clp2a6r8t005yl70d7e8f9g0h",
+ "name": "Footer",
+ "lmx": ""
+}
+```
+
+---
+
+### updateComponent()
+
+Update a component's name and/or LMX body. When `lmx` changes, the update cascades to emails using the component; `affectedEmailCount` reports how many were affected.
+[API Reference](https://loops.so/docs/api-reference/update-component)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------ | ------ | -------- | ------------------------------------- |
+| `componentId` | string | Yes | The ID of the component. |
+| `name` | string | No | The component name. |
+| `lmx` | string | No | The component body as an LMX string. |
+
+#### Example
+
+```javascript
+const resp = await loops.updateComponent("clp2a6r8t005yl70d7e8f9g0h", {
+ lmx: "",
+});
+```
+
+#### Response
+
+```json
+{
+ "id": "clp2a6r8t005yl70d7e8f9g0h",
+ "name": "Footer",
+ "lmx": "",
+ "affectedEmailCount": 2
+}
+```
+
+---
+
### listCampaigns()
Retrieve a paginated list of campaigns.
@@ -1562,6 +1804,30 @@ Retrieve a paginated list of transactional groups.
const resp = await loops.listTransactionalGroups();
```
+#### Response
+
+```json
+{
+ "pagination": {
+ "totalResults": 1,
+ "returnedResults": 1,
+ "perPage": 20,
+ "totalPages": 1,
+ "nextCursor": null,
+ "nextPage": null
+ },
+ "data": [
+ {
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Onboarding",
+ "description": "Top of funnel campaigns",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+ }
+ ]
+}
+```
+
---
### createTransactionalGroup()
@@ -1585,6 +1851,18 @@ const resp = await loops.createTransactionalGroup({
});
```
+#### Response
+
+```json
+{
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Onboarding",
+ "description": "Top of funnel campaigns",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+}
+```
+
---
### getTransactionalGroup()
@@ -1604,6 +1882,18 @@ Retrieve a single transactional group by ID.
const resp = await loops.getTransactionalGroup("clq3b7s9u006yl70p5q6r7s8t");
```
+#### Response
+
+```json
+{
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Onboarding",
+ "description": "Top of funnel campaigns",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+}
+```
+
---
### updateTransactionalGroup()
@@ -1627,6 +1917,18 @@ const resp = await loops.updateTransactionalGroup("clq3b7s9u006yl70p5q6r7s8t", {
});
```
+#### Response
+
+```json
+{
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Updated name",
+ "description": "Top of funnel campaigns",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+}
+```
+
---
### listAudienceSegments()
@@ -1647,6 +1949,41 @@ Retrieve a paginated list of audience segments.
const resp = await loops.listAudienceSegments();
```
+#### Response
+
+```json
+{
+ "pagination": {
+ "totalResults": 1,
+ "returnedResults": 1,
+ "perPage": 20,
+ "totalPages": 1,
+ "nextCursor": null,
+ "nextPage": null
+ },
+ "data": [
+ {
+ "id": "cls6e8g0i2k4m6o8q0s2u4w6",
+ "name": "Power users",
+ "description": "Contacts on the pro plan",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "filter": {
+ "match": "all",
+ "conditions": [
+ {
+ "type": "property",
+ "key": "planName",
+ "operator": "equals",
+ "value": "pro"
+ }
+ ]
+ }
+ }
+ ]
+}
+```
+
---
### getAudienceSegment()
@@ -1666,6 +2003,87 @@ Retrieve a single audience segment by ID.
const resp = await loops.getAudienceSegment("clr4c8t0v008yl70x3y4z5a6b");
```
+#### Response
+
+```json
+{
+ "id": "cls6e8g0i2k4m6o8q0s2u4w6",
+ "name": "Power users",
+ "description": "Contacts on the pro plan",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "filter": {
+ "match": "all",
+ "conditions": [
+ {
+ "type": "property",
+ "key": "planName",
+ "operator": "equals",
+ "value": "pro"
+ }
+ ]
+ }
+}
+```
+
+---
+
+### createAudienceSegment()
+
+Create a new audience segment.
+[API Reference](https://loops.so/docs/api-reference/create-audience-segment)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ---- | ---- | -------- | ----- |
+| `name` | string | Yes | The name of the audience segment. Must be unique within the team. |
+| `description` | string | No | An optional description of the audience segment. |
+| `filter` | object | Yes | A tree of audience conditions combined with `match` (`all` or `any`). |
+
+#### Example
+
+```javascript
+const resp = await loops.createAudienceSegment({
+ name: "Active users",
+ description: "Contacts who opened a recent campaign",
+ filter: {
+ match: "all",
+ conditions: [
+ {
+ type: "property",
+ key: "planName",
+ operator: "equals",
+ value: "pro",
+ },
+ ],
+ },
+});
+```
+
+#### Response
+
+```json
+{
+ "id": "cls6e8g0i2k4m6o8q0s2u4w6",
+ "name": "Active users",
+ "description": "Contacts who opened a recent campaign",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z",
+ "filter": {
+ "match": "all",
+ "conditions": [
+ {
+ "type": "property",
+ "key": "planName",
+ "operator": "equals",
+ "value": "pro"
+ }
+ ]
+ }
+}
+```
+
---
### listCampaignGroups()
@@ -1686,6 +2104,30 @@ Retrieve a paginated list of campaign groups.
const resp = await loops.listCampaignGroups();
```
+#### Response
+
+```json
+{
+ "pagination": {
+ "totalResults": 1,
+ "returnedResults": 1,
+ "perPage": 20,
+ "totalPages": 1,
+ "nextCursor": null,
+ "nextPage": null
+ },
+ "data": [
+ {
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Newsletters",
+ "description": "Monthly product updates",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+ }
+ ]
+}
+```
+
---
### createCampaignGroup()
@@ -1708,6 +2150,18 @@ const resp = await loops.createCampaignGroup({
});
```
+#### Response
+
+```json
+{
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Newsletters",
+ "description": "",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+}
+```
+
---
### getCampaignGroup()
@@ -1727,6 +2181,18 @@ Retrieve a single campaign group by ID.
const resp = await loops.getCampaignGroup("clq3b7s9u007yl70u9v0w1x2y");
```
+#### Response
+
+```json
+{
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Newsletters",
+ "description": "Monthly product updates",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+}
+```
+
---
### updateCampaignGroup()
@@ -1750,12 +2216,24 @@ const resp = await loops.updateCampaignGroup("clq3b7s9u007yl70u9v0w1x2y", {
});
```
----
-
-### sendEmailMessagePreview()
+#### Response
-Send a test preview of an email message to one or more addresses.
-[API Reference](https://loops.so/docs/api-reference/send-email-message-preview)
+```json
+{
+ "id": "clg7n5p3q1r9s7t5u3v1w9y7",
+ "name": "Newsletters",
+ "description": "Monthly product updates",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+}
+```
+
+---
+
+### sendEmailMessagePreview()
+
+Send a test preview of an email message to one or more addresses.
+[API Reference](https://loops.so/docs/api-reference/preview-email-message)
#### Parameters
@@ -1778,6 +2256,183 @@ const resp = await loops.sendEmailMessagePreview("clm9x3o5q002yl70a8b3c4d5e", {
});
```
+#### Response
+
+```json
+{
+ "id": "cle5f7g9h1i3j5k7l9m1n3p5"
+}
+```
+
+---
+
+### runEmailMessageGuardian()
+
+Validate an email message against Guardian rules. Errors must be resolved before publishing; warnings are advisory.
+[API Reference](https://loops.so/docs/api-reference/run-guardian-checks)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ---- | ------ | -------- | ------------------------------ |
+| `emailMessageId` | string | Yes | The ID of the email message. |
+
+#### Example
+
+```javascript
+const resp = await loops.runEmailMessageGuardian("clm9x3o5q002yl70a8b3c4d5e");
+```
+
+#### Response
+
+```json
+{
+ "errors": [
+ {
+ "rule": "missingButtonHrefs",
+ "title": "Missing button link",
+ "description": "Buttons won't work without href value",
+ "items": [
+ {
+ "label": "Click here"
+ }
+ ]
+ },
+ {
+ "rule": "missingLinkHrefs",
+ "title": "Missing text link",
+ "description": "Links won't work without href value",
+ "items": [
+ {
+ "label": "See more"
+ }
+ ]
+ }
+ ],
+ "warnings": []
+}
+```
+
+---
+
+### listEventPatterns()
+
+Retrieve a paginated list of event patterns available to workflow event trigger nodes.
+[API Reference](https://loops.so/docs/api-reference/list-event-patterns)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `perPage` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```javascript
+const resp = await loops.listEventPatterns();
+```
+
+#### Response
+
+```json
+{
+ "pagination": {
+ "totalResults": 1,
+ "returnedResults": 1,
+ "perPage": 20,
+ "totalPages": 1,
+ "nextCursor": null,
+ "nextPage": null
+ },
+ "data": [
+ {
+ "id": "cle1a2b3c004yl70d5e6f7g8h",
+ "eventName": "signup",
+ "incomingWebhookPlatform": null
+ }
+ ]
+}
+```
+
+---
+
+### getEventPattern()
+
+Retrieve event pattern details by ID.
+[API Reference](https://loops.so/docs/api-reference/get-event-pattern)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ---- | ------ | -------- | ---------------------------- |
+| `eventPatternId` | string | Yes | The ID of the event pattern. |
+
+#### Example
+
+```javascript
+const resp = await loops.getEventPattern("cle1a2b3c004yl70d5e6f7g8h");
+```
+
+#### Response
+
+```json
+{
+ "id": "cle1a2b3c004yl70d5e6f7g8h",
+ "eventName": "signup",
+ "eventProperties": [
+ {
+ "name": "plan",
+ "type": "string"
+ },
+ {
+ "name": "trialDays",
+ "type": "number"
+ }
+ ],
+ "incomingWebhookPlatform": null
+}
+```
+
+---
+
+### getEventPatternByName()
+
+Retrieve event pattern details by event name.
+[API Reference](https://loops.so/docs/api-reference/get-event-pattern-by-name)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ---------- | ------ | -------- | ------------------------------ |
+| `eventName` | string | Yes | The name of the event pattern. |
+
+#### Example
+
+```javascript
+const resp = await loops.getEventPatternByName("signup");
+```
+
+#### Response
+
+```json
+{
+ "id": "cle1a2b3c004yl70d5e6f7g8h",
+ "eventName": "signup",
+ "eventProperties": [
+ {
+ "name": "plan",
+ "type": "string"
+ },
+ {
+ "name": "trialDays",
+ "type": "number"
+ }
+ ],
+ "incomingWebhookPlatform": null
+}
+```
+
---
### listWorkflows()
@@ -1798,11 +2453,80 @@ Retrieve a paginated list of workflows.
const resp = await loops.listWorkflows();
```
+#### Response
+
+```json
+{
+ "pagination": {
+ "totalResults": 1,
+ "returnedResults": 1,
+ "perPage": 20,
+ "totalPages": 1,
+ "nextCursor": null,
+ "nextPage": null
+ },
+ "data": [
+ {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "name": "Onboarding",
+ "createdAt": "2025-06-29T07:47:39.370Z",
+ "updatedAt": "2025-06-29T07:47:39.370Z"
+ }
+ ]
+}
+```
+
+---
+
+### createWorkflow()
+
+Create a draft workflow with a blank trigger and exit node.
+[API Reference](https://loops.so/docs/api-reference/create-workflow)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | -------------- | -------- | -------------------------------------------------- |
+| `name` | string | Yes | The name of the workflow. |
+| `description` | string | No | The description of the workflow. |
+| `mailingListId` | string \| null | No | The ID of a mailing list the workflow sends to. |
+
+#### Example
+
+```javascript
+const resp = await loops.createWorkflow({ name: "Onboarding" });
+```
+
+#### Response
+
+```json
+{
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "cf16k73gq014h3mmj5b6jdi9r",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {
+ "cf16k73gq014h3mmj5b6jdi9r": {
+ "typeName": "BlankTrigger",
+ "nextNodeIds": [
+ "cf16k73gq014h3mmj5b4jdifg"
+ ]
+ },
+ "cf16k73gq014h3mmj5b4jdifg": {
+ "typeName": "ExitAction",
+ "nextNodeIds": []
+ }
+ }
+}
+```
+
---
### getWorkflow()
-Retrieve a workflow graph with node type names, connections, and selected display fields.
+Retrieve a workflow graph with node type names, connections, and selected display fields. Includes `workflowRevisionId` (may be `null` for older workflows) for subsequent mutations.
[API Reference](https://loops.so/docs/api-reference/get-workflow)
#### Parameters
@@ -1817,11 +2541,201 @@ Retrieve a workflow graph with node type names, connections, and selected displa
const resp = await loops.getWorkflow("cls5d9u1w009yl70c7d8e9f0g");
```
+#### Response
+
+```json
+{
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "cf16k73gq014h3mmj5b6jdi9r",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {
+ "cf16k73gq014h3mmj5b6jdi9r": {
+ "typeName": "SignupTrigger",
+ "nextNodeIds": [
+ "cf16k73gq014h3mmj5b4jdifg"
+ ]
+ },
+ "cf16k73gq014h3mmj5b4jdifg": {
+ "typeName": "ExitAction",
+ "nextNodeIds": []
+ }
+ }
+}
+```
+
+---
+
+### updateWorkflow()
+
+Update a workflow's name and/or description. To change the mailing list, use [`changeWorkflowMailingList()`](#changeworkflowmailinglist).
+[API Reference](https://loops.so/docs/api-reference/update-workflow)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------ | -------- | ------------------------------------------------------------------ |
+| `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. |
+| `name` | string | No | The updated workflow name. |
+| `description` | string | No | The updated workflow description. |
+
+#### Example
+
+```javascript
+const resp = await loops.updateWorkflow("cls5d9u1w009yl70c7d8e9f0g", {
+ expectedRevisionId: "rev_1",
+ name: "Updated onboarding",
+});
+```
+
+#### Response
+
+```json
+{
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Updated onboarding",
+ "mailingListId": null,
+ "rootNodeId": "cf16k73gq014h3mmj5b6jdi9r",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {
+ "cf16k73gq014h3mmj5b6jdi9r": {
+ "typeName": "BlankTrigger",
+ "nextNodeIds": [
+ "cf16k73gq014h3mmj5b4jdifg"
+ ]
+ },
+ "cf16k73gq014h3mmj5b4jdifg": {
+ "typeName": "ExitAction",
+ "nextNodeIds": []
+ }
+ }
+}
+```
+
+---
+
+### changeWorkflowMailingList()
+
+Dry run or apply a workflow mailing list change. If queued contacts would be removed, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"` to apply.
+[API Reference](https://loops.so/docs/api-reference/change-workflow-mailing-list)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------------------ | -------- | ------------------------------------------------------------------ |
+| `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. |
+| `mailingListId` | string \| null | Yes | The mailing list to use, or `null` to clear it. |
+| `dryRun` | boolean | No | If `true`, validate without modifying the workflow. |
+| `queuedContactPolicy`| `"fail"` \| `"discard"` | No | How to handle queued contacts that would be removed. |
+
+#### Example
+
+```javascript
+const resp = await loops.changeWorkflowMailingList("cls5d9u1w009yl70c7d8e9f0g", {
+ expectedRevisionId: "rev_1",
+ mailingListId: "clm1a2b3c004yl70d5e6f7g8h",
+});
+```
+
+#### Response
+
+```json
+{
+ "status": "updated",
+ "mailingListId": "clm1a2b3c004yl70d5e6f7g8h",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "queuedContactCount": 0,
+ "queuedContactLimitReached": false
+}
+```
+
+---
+
+### createWorkflowNode()
+
+Create a new default workflow node. Use `insertMode: "between"` or `insertMode: "before"`, then configure with [`updateWorkflowNode()`](#updateworkflownode).
+[API Reference](https://loops.so/docs/api-reference/create-workflow-node)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------ | -------- | --------------------------------------------------------------------- |
+| `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"`. |
+| `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"`. |
+
+#### Example
+
+```javascript
+const resp = await loops.createWorkflowNode("cls5d9u1w009yl70c7d8e9f0g", {
+ expectedRevisionId: "rev_1",
+ insertMode: "between",
+ nodeTypeName: "TimerAction",
+ fromNodeId: "clt6e0v2x010yl70h1i2j3k4l",
+ toNodeId: "clu7f1w3y011yl70m5n6o7p8q",
+});
+```
+
+#### Response
+
+```json
+{
+ "node": {
+ "id": "cln8p0q2r4s6t8u0v2w4x6z8",
+ "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "typeName": "TimerAction",
+ "nextNodeIds": [
+ "clu7f1w3y011yl70m5n6o7p8q"
+ ],
+ "amount": 0,
+ "unit": "m",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6"
+ },
+ "workflow": {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {
+ "clt6e0v2x010yl70h1i2j3k4l": {
+ "typeName": "BlankTrigger",
+ "nextNodeIds": [
+ "cln8p0q2r4s6t8u0v2w4x6z8"
+ ]
+ },
+ "cln8p0q2r4s6t8u0v2w4x6z8": {
+ "typeName": "TimerAction",
+ "nextNodeIds": [
+ "clu7f1w3y011yl70m5n6o7p8q"
+ ],
+ "amount": 0,
+ "unit": "m"
+ },
+ "clu7f1w3y011yl70m5n6o7p8q": {
+ "typeName": "ExitAction",
+ "nextNodeIds": []
+ }
+ }
+ }
+}
+```
+
---
### getWorkflowNode()
-Retrieve detailed data for a single workflow node.
+Retrieve detailed data for a single workflow node. Includes `workflowRevisionId` (may be `null` for older workflows).
[API Reference](https://loops.so/docs/api-reference/get-workflow-node)
#### Parameters
@@ -1837,6 +2751,212 @@ Retrieve detailed data for a single workflow node.
const resp = await loops.getWorkflowNode("cls5d9u1w009yl70c7d8e9f0g", "clt6e0v2x010yl70h1i2j3k4l");
```
+#### Response
+
+```json
+{
+ "id": "cln8p0q2r4s6t8u0v2w4x6z8",
+ "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "typeName": "TimerAction",
+ "nextNodeIds": [
+ "cf16k73gq014h3mmj5b4jdifg"
+ ],
+ "amount": 1,
+ "unit": "h",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6"
+}
+```
+
+---
+
+### updateWorkflowNode()
+
+Update workflow-node-owned fields for a single node.
+[API Reference](https://loops.so/docs/api-reference/update-workflow-node)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------ | -------- | ------------------------------------------------------------------ |
+| `workflowId` | string | Yes | The ID of the workflow. |
+| `nodeId` | string | Yes | The ID of the 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. |
+| `payload` | object | Yes | Node-type-specific fields to update. |
+
+#### Example
+
+```javascript
+const resp = await loops.updateWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "clt6e0v2x010yl70h1i2j3k4l",
+ {
+ expectedRevisionId: "rev_1",
+ payload: { amount: 1, unit: "h" },
+ }
+);
+```
+
+#### Response
+
+```json
+{
+ "id": "cln8p0q2r4s6t8u0v2w4x6z8",
+ "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "typeName": "TimerAction",
+ "nextNodeIds": [
+ "cf16k73gq014h3mmj5b4jdifg"
+ ],
+ "amount": 1,
+ "unit": "h",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6"
+}
+```
+
+---
+
+### deleteWorkflowNode()
+
+Delete a single workflow node. If contacts are queued, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"`.
+[API Reference](https://loops.so/docs/api-reference/delete-workflow-node)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------------------ | -------- | ------------------------------------------------------------------ |
+| `workflowId` | string | Yes | The ID of the workflow. |
+| `nodeId` | string | Yes | The ID of the 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. |
+| `dryRun` | boolean | No | If `true`, validate without modifying the workflow. |
+| `queuedContactPolicy`| `"fail"` \| `"discard"` | No | How to handle queued contacts. |
+
+#### Example
+
+```javascript
+const resp = await loops.deleteWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "clt6e0v2x010yl70h1i2j3k4l",
+ { expectedRevisionId: "rev_1" }
+);
+```
+
+#### Response
+
+```json
+{
+ "status": "deleted",
+ "nodeIds": [
+ "cln8p0q2r4s6t8u0v2w4x6z8"
+ ],
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "queuedContactCount": 0,
+ "queuedContactLimitReached": false
+}
+```
+
+---
+
+### addWorkflowBranch()
+
+Add a branch and a child node under an existing Branch or Experiment node.
+[API Reference](https://loops.so/docs/api-reference/add-workflow-branch)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------ | -------- | ------------------------------------------------------------------ |
+| `workflowId` | string | Yes | The ID of the workflow. |
+| `nodeId` | string | Yes | The ID of the Branch or Experiment 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. |
+
+#### Example
+
+```javascript
+const resp = await loops.addWorkflowBranch(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "clt6e0v2x010yl70h1i2j3k4l",
+ { expectedRevisionId: "rev_1" }
+);
+```
+
+#### Response
+
+```json
+{
+ "node": {
+ "id": "cln0a2b4c6d8e0f2g4h6i8j0",
+ "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "typeName": "AudienceFilter",
+ "nextNodeIds": [],
+ "audienceSegmentId": null,
+ "audienceFilter": null,
+ "appliesDownstream": false,
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6"
+ },
+ "workflow": {
+ "id": "clw1a3b5c7d9e1f3g5h7i9j1",
+ "status": "Draft",
+ "name": "Onboarding",
+ "mailingListId": null,
+ "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l",
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "nodes": {
+ "clt6e0v2x010yl70h1i2j3k4l": {
+ "typeName": "BranchNode",
+ "nextNodeIds": [
+ "cln0a2b4c6d8e0f2g4h6i8j0"
+ ]
+ },
+ "cln0a2b4c6d8e0f2g4h6i8j0": {
+ "typeName": "AudienceFilter",
+ "nextNodeIds": []
+ }
+ }
+ }
+}
+```
+
+---
+
+### deleteWorkflowNodesRecursive()
+
+Delete a node and its downstream subtree. If contacts are queued, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"`.
+[API Reference](https://loops.so/docs/api-reference/delete-workflow-nodes)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------------------ | -------- | ------------------------------------------------------------------ |
+| `workflowId` | string | Yes | The ID of the workflow. |
+| `nodeId` | string | Yes | The ID of the 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. |
+| `dryRun` | boolean | No | If `true`, validate without modifying the workflow. |
+| `queuedContactPolicy`| `"fail"` \| `"discard"` | No | How to handle queued contacts. |
+
+#### Example
+
+```javascript
+const resp = await loops.deleteWorkflowNodesRecursive(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "clt6e0v2x010yl70h1i2j3k4l",
+ { expectedRevisionId: "rev_1", queuedContactPolicy: "discard" }
+);
+```
+
+#### Response
+
+```json
+{
+ "status": "deleted",
+ "nodeIds": [
+ "cln8p0q2r4s6t8u0v2w4x6z8",
+ "cln9q1r3s5t7u9v1w3x5y7z9"
+ ],
+ "workflowRevisionId": "clrev0w0r1k2f3l4o5w6",
+ "queuedContactCount": 0,
+ "queuedContactLimitReached": false
+}
+```
+
---
### createUpload()
@@ -1861,6 +2981,15 @@ const resp = await loops.createUpload({
});
```
+#### Response
+
+```json
+{
+ "emailAssetId": "cla3s5s7e9t1i3d5f7g9h1j3",
+ "presignedUrl": "https://loops-assets.s3.amazonaws.com/uploads/cla3s5s7e9t1i3d5f7g9h1j3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=..."
+}
+```
+
---
### completeUpload()
@@ -1881,10 +3010,26 @@ Finalize an asset after the file has been uploaded to the pre-signed URL.
const resp = await loops.completeUpload("clu7f1w3y011yl70m5n6o7p8q");
```
+#### Response
+
+```json
+{
+ "emailAssetId": "cla3s5s7e9t1i3d5f7g9h1j3",
+ "finalUrl": "https://assets.loops.so/cla3s5s7e9t1i3d5f7g9h1j3/logo.png"
+}
+```
+
---
## Version history
+- `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)).
+ - Added theme and component write APIs ([`createTheme()`](#createtheme), [`updateTheme()`](#updatetheme), [`createComponent()`](#createcomponent), [`updateComponent()`](#updatecomponent)).
+ - Added [`createAudienceSegment()`](#createaudiencesegment).
+ - Added [`runEmailMessageGuardian()`](#runemailmessageguardian).
+ - Aligned workflow response types with the API (`status`, nullable `workflowRevisionId`; removed unused `emoji`).
- `v7.0.0` (Jun 24, 2026)
- Added transactional email management ([`getTransactionalEmail()`](#gettransactionalemail), [`createTransactionalEmail()`](#createtransactionalemail), [`updateTransactionalEmail()`](#updatetransactionalemail), [`ensureTransactionalEmailDraft()`](#ensuretransactionalemaildraft), [`publishTransactionalEmail()`](#publishtransactionalemail)).
- Added workflows ([`listWorkflows()`](#listworkflows), [`getWorkflow()`](#getworkflow), [`getWorkflowNode()`](#getworkflownode)).
diff --git a/package-lock.json b/package-lock.json
index 2485f5b..5373ee6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loops",
- "version": "6.4.0",
+ "version": "7.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loops",
- "version": "6.4.0",
+ "version": "7.1.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.12",
diff --git a/package.json b/package.json
index 002d7cb..390cae5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "loops",
- "version": "7.0.0",
+ "version": "7.1.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 e16d13d..c4feba0 100644
--- a/src/__tests__/LoopsClient.test.ts
+++ b/src/__tests__/LoopsClient.test.ts
@@ -1116,6 +1116,130 @@ describe("LoopsClient", () => {
});
});
+ describe("createAudienceSegment", () => {
+ it("should create an audience segment", async () => {
+ const filter = {
+ match: "all" as const,
+ conditions: [
+ {
+ type: "property" as const,
+ key: "planName",
+ operator: "equals" as const,
+ value: "pro",
+ },
+ ],
+ };
+ const mockResponse = {
+ id: "cls6e8g0i2k4m6o8q0s2u4w6",
+ name: "Active users",
+ description: "Contacts on the pro plan",
+ createdAt: "2025-06-29T07:47:39.370Z",
+ updatedAt: "2025-06-29T07:47:39.370Z",
+ filter,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.createAudienceSegment({
+ name: "Active users",
+ description: "Contacts on the pro plan",
+ filter,
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/audience-segments"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ name: "Active users",
+ filter,
+ description: "Contacts on the pro plan",
+ }),
+ })
+ );
+ });
+
+ it("should handle error when segment name already exists", async () => {
+ const filter = {
+ match: "all" as const,
+ conditions: [
+ {
+ type: "property" as const,
+ key: "planName",
+ operator: "equals" as const,
+ value: "pro",
+ },
+ ],
+ };
+ const mockResponse = {
+ message: "An audience segment with this name already exists",
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ await expect(
+ client.createAudienceSegment({
+ name: "Active users",
+ filter,
+ })
+ ).rejects.toThrow(APIError);
+
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/audience-segments"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ name: "Active users",
+ filter,
+ }),
+ })
+ );
+ });
+
+ it("should handle error for an invalid filter", async () => {
+ const filter = {
+ match: "all" as const,
+ conditions: [
+ {
+ type: "property" as const,
+ key: "planName",
+ operator: "equals" as const,
+ value: "pro",
+ },
+ ],
+ };
+ const mockResponse = {
+ message: "Invalid filter",
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ try {
+ await client.createAudienceSegment({
+ name: "Active users",
+ filter,
+ });
+ fail("Should have thrown");
+ } catch (error) {
+ expect(error).toBeInstanceOf(APIError);
+ expect((error as APIError).statusCode).toBe(400);
+ expect((error as APIError).json).toEqual(mockResponse);
+ }
+ });
+ });
+
describe("listThemes", () => {
it("should list themes with pagination", async () => {
const mockResponse = {
@@ -1449,4 +1573,564 @@ describe("LoopsClient", () => {
).rejects.toThrow(APIError);
});
});
+
+ describe("createTheme", () => {
+ it("should create a theme", async () => {
+ const mockResponse = {
+ id: "clo1z5q7s004yl70y3z4a5b6c",
+ name: "Brand",
+ styles: { backgroundColor: "#111111" },
+ isDefault: false,
+ createdAt: "2025-01-01T00:00:00.000Z",
+ updatedAt: "2025-01-01T00:00:00.000Z",
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.createTheme({
+ name: "Brand",
+ styles: { backgroundColor: "#111111" },
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/themes"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ name: "Brand",
+ styles: { backgroundColor: "#111111" },
+ }),
+ })
+ );
+ });
+ });
+
+ describe("updateTheme", () => {
+ it("should update a theme", async () => {
+ const mockResponse = {
+ id: "clo1z5q7s004yl70y3z4a5b6c",
+ name: "Brand Updated",
+ styles: {},
+ isDefault: false,
+ createdAt: "2025-01-01T00:00:00.000Z",
+ updatedAt: "2025-01-02T00:00:00.000Z",
+ affectedEmailCount: 3,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.updateTheme("clo1z5q7s004yl70y3z4a5b6c", {
+ name: "Brand Updated",
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/themes/clo1z5q7s004yl70y3z4a5b6c"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ name: "Brand Updated" }),
+ })
+ );
+ });
+ });
+
+ describe("createComponent", () => {
+ it("should create a component", async () => {
+ const mockResponse = {
+ id: "clp2a6r8t005yl70d7e8f9g0h",
+ name: "Footer",
+ lmx: "",
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.createComponent({
+ name: "Footer",
+ lmx: "",
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/components"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ name: "Footer", lmx: "" }),
+ })
+ );
+ });
+ });
+
+ describe("updateComponent", () => {
+ it("should update a component", async () => {
+ const mockResponse = {
+ id: "clp2a6r8t005yl70d7e8f9g0h",
+ name: "Footer",
+ lmx: "",
+ affectedEmailCount: 2,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.updateComponent("clp2a6r8t005yl70d7e8f9g0h", {
+ lmx: "",
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/components/clp2a6r8t005yl70d7e8f9g0h"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ lmx: "" }),
+ })
+ );
+ });
+ });
+
+ describe("runEmailMessageGuardian", () => {
+ it("should run guardian checks", async () => {
+ const mockResponse = {
+ errors: [
+ {
+ rule: "missingButtonHrefs",
+ title: "Missing button link",
+ description: "Buttons won't work without href value",
+ items: [{ label: "Click here" }],
+ },
+ ],
+ warnings: [],
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.runEmailMessageGuardian(
+ "clm9x3o5q002yl70a8b3c4d5e"
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/email-messages/clm9x3o5q002yl70a8b3c4d5e/guardian"
+ ),
+ expect.objectContaining({ method: "GET" })
+ );
+ });
+ });
+
+ describe("listEventPatterns", () => {
+ it("should list event patterns with pagination", async () => {
+ const mockResponse = {
+ pagination: {
+ totalResults: 1,
+ returnedResults: 1,
+ perPage: 20,
+ totalPages: 1,
+ nextCursor: null,
+ nextPage: null,
+ },
+ data: [
+ {
+ id: "cle1a2b3c004yl70d5e6f7g8h",
+ eventName: "signup",
+ incomingWebhookPlatform: null,
+ },
+ ],
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.listEventPatterns({ perPage: 10 });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/event-patterns?perPage=10"),
+ expect.objectContaining({ method: "GET" })
+ );
+ });
+ });
+
+ describe("getEventPattern", () => {
+ it("should get an event pattern by ID", async () => {
+ const mockResponse = {
+ id: "cle1a2b3c004yl70d5e6f7g8h",
+ eventName: "signup",
+ eventProperties: [{ name: "plan", type: "string" }],
+ incomingWebhookPlatform: null,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.getEventPattern("cle1a2b3c004yl70d5e6f7g8h");
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/event-patterns/cle1a2b3c004yl70d5e6f7g8h"),
+ expect.objectContaining({ method: "GET" })
+ );
+ });
+ });
+
+ describe("getEventPatternByName", () => {
+ it("should get an event pattern by name", async () => {
+ const mockResponse = {
+ id: "cle1a2b3c004yl70d5e6f7g8h",
+ eventName: "signup completed",
+ eventProperties: [],
+ incomingWebhookPlatform: null,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.getEventPatternByName("signup completed");
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/event-patterns/by-name/signup%20completed"
+ ),
+ expect.objectContaining({ method: "GET" })
+ );
+ });
+ });
+
+ describe("createWorkflow", () => {
+ it("should create a workflow", async () => {
+ const mockResponse = {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ name: "Onboarding",
+ mailingListId: null,
+ rootNodeId: "clt6e0v2x010yl70h1i2j3k4l",
+ nodes: {},
+ workflowRevisionId: "rev_1",
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.createWorkflow({ name: "Onboarding" });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/workflows"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ name: "Onboarding" }),
+ })
+ );
+ });
+ });
+
+ describe("updateWorkflow", () => {
+ it("should update a workflow", async () => {
+ const mockResponse = {
+ id: "cls5d9u1w009yl70c7d8e9f0g",
+ status: "Draft",
+ name: "Updated",
+ mailingListId: null,
+ rootNodeId: "clt6e0v2x010yl70h1i2j3k4l",
+ nodes: {},
+ workflowRevisionId: "rev_2",
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.updateWorkflow("cls5d9u1w009yl70c7d8e9f0g", {
+ expectedRevisionId: "rev_1",
+ name: "Updated",
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("v1/workflows/cls5d9u1w009yl70c7d8e9f0g"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ expectedRevisionId: "rev_1",
+ name: "Updated",
+ }),
+ })
+ );
+ });
+ });
+
+ describe("changeWorkflowMailingList", () => {
+ it("should change a workflow mailing list", async () => {
+ const mockResponse = {
+ status: "updated",
+ mailingListId: "clm1",
+ workflowRevisionId: "rev_2",
+ queuedContactCount: 0,
+ queuedContactLimitReached: false,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.changeWorkflowMailingList(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ {
+ expectedRevisionId: "rev_1",
+ mailingListId: "clm1",
+ }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/mailing-list"
+ ),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ expectedRevisionId: "rev_1",
+ mailingListId: "clm1",
+ }),
+ })
+ );
+ });
+ });
+
+ describe("createWorkflowNode", () => {
+ it("should create a workflow node between two nodes", async () => {
+ const mockResponse = {
+ node: {
+ id: "new_node",
+ workflowId: "cls5d9u1w009yl70c7d8e9f0g",
+ typeName: "TimerAction",
+ nextNodeIds: ["to"],
+ 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: "between",
+ nodeTypeName: "TimerAction",
+ fromNodeId: "from",
+ 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: "between",
+ nodeTypeName: "TimerAction",
+ fromNodeId: "from",
+ toNodeId: "to",
+ }),
+ })
+ );
+ });
+ });
+
+ describe("updateWorkflowNode", () => {
+ it("should update a workflow node", async () => {
+ const mockResponse = {
+ id: "node1",
+ workflowId: "cls5d9u1w009yl70c7d8e9f0g",
+ typeName: "TimerAction",
+ nextNodeIds: [],
+ amount: 1,
+ unit: "h",
+ workflowRevisionId: "rev_2",
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.updateWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "node1",
+ {
+ expectedRevisionId: "rev_1",
+ payload: { amount: 1, unit: "h" },
+ }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1"
+ ),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ expectedRevisionId: "rev_1",
+ payload: { amount: 1, unit: "h" },
+ }),
+ })
+ );
+ });
+ });
+
+ describe("deleteWorkflowNode", () => {
+ it("should delete a workflow node", async () => {
+ const mockResponse = {
+ status: "deleted",
+ nodeIds: ["node1"],
+ workflowRevisionId: "rev_2",
+ queuedContactCount: 0,
+ queuedContactLimitReached: false,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.deleteWorkflowNode(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "node1",
+ { expectedRevisionId: "rev_1" }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1"
+ ),
+ expect.objectContaining({
+ method: "DELETE",
+ body: JSON.stringify({ expectedRevisionId: "rev_1" }),
+ })
+ );
+ });
+ });
+
+ describe("addWorkflowBranch", () => {
+ it("should add a workflow branch", async () => {
+ const mockResponse = {
+ node: {
+ id: "branch_child",
+ workflowId: "cls5d9u1w009yl70c7d8e9f0g",
+ typeName: "AudienceFilter",
+ nextNodeIds: [],
+ 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.addWorkflowBranch(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "branch1",
+ { expectedRevisionId: "rev_1" }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/branch1/add-branch"
+ ),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ expectedRevisionId: "rev_1" }),
+ })
+ );
+ });
+ });
+
+ describe("deleteWorkflowNodesRecursive", () => {
+ it("should recursively delete workflow nodes", async () => {
+ const mockResponse = {
+ status: "deleted",
+ nodeIds: ["node1", "node2"],
+ workflowRevisionId: "rev_2",
+ queuedContactCount: 0,
+ queuedContactLimitReached: false,
+ };
+
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
+ });
+
+ const result = await client.deleteWorkflowNodesRecursive(
+ "cls5d9u1w009yl70c7d8e9f0g",
+ "node1",
+ { expectedRevisionId: "rev_1", queuedContactPolicy: "discard" }
+ );
+
+ expect(result).toEqual(mockResponse);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1/recursive"
+ ),
+ expect.objectContaining({
+ method: "DELETE",
+ body: JSON.stringify({
+ expectedRevisionId: "rev_1",
+ queuedContactPolicy: "discard",
+ }),
+ })
+ );
+ });
+ });
});
diff --git a/src/index.ts b/src/index.ts
index ebc5494..b4355a4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -351,6 +351,39 @@ interface ListAudienceSegmentsResponse {
data: AudienceSegment[];
}
+type IncomingWebhookPlatform = "clerk" | "polar" | "stripe" | "supabase";
+
+interface EventPatternSummary {
+ /** The ID of the event pattern. */
+ id: string;
+ /**
+ * The name of the event pattern. Use this when sending events with the API.
+ */
+ eventName: string;
+ /**
+ * The platform that sent this event pattern, if the event pattern is from an
+ * incoming webhook. `null` for custom events.
+ */
+ incomingWebhookPlatform: IncomingWebhookPlatform | null;
+}
+
+interface WorkflowEventProperty {
+ name: string;
+ type: "string" | "number" | "boolean" | "date";
+}
+
+interface EventPattern extends EventPatternSummary {
+ /**
+ * The properties of the event pattern, which can be used in emails.
+ */
+ eventProperties: WorkflowEventProperty[];
+}
+
+interface ListEventPatternsResponse {
+ pagination: PaginationData;
+ data: EventPatternSummary[];
+}
+
interface WorkflowSummary {
id: string;
name: string;
@@ -363,6 +396,8 @@ interface ListWorkflowsResponse {
data: WorkflowSummary[];
}
+type WorkflowStatus = "Draft" | "Sending" | "Paused" | "PausedAndQueueing";
+
type SimplifiedWorkflowNode = {
typeName: string;
nextNodeIds: string[];
@@ -370,11 +405,17 @@ type SimplifiedWorkflowNode = {
interface SimplifiedWorkflow {
id: string;
+ status: WorkflowStatus;
name?: string;
description?: string;
- emoji?: string;
- mailingListId?: string | null;
- rootNodeId: string | null;
+ mailingListId: string | null;
+ rootNodeId: string;
+ /**
+ * The current workflow revision token. Pass the latest value as
+ * `expectedRevisionId` on the next workflow mutation. `null` for workflows
+ * that do not have a revision token yet.
+ */
+ workflowRevisionId: string | null;
nodes: Record;
}
@@ -385,11 +426,215 @@ type WorkflowNode = {
nextNodeIds: string[];
} & Record;
+type WorkflowNodeWithRevision = WorkflowNode & {
+ /**
+ * The current workflow revision token. `null` for workflows that do not
+ * have a revision token yet.
+ */
+ workflowRevisionId: string | null;
+};
+
+type WorkflowQueuedContactPolicy = "fail" | "discard";
+
+/** Pass the latest `workflowRevisionId`, including `null` for older workflows. */
+type WorkflowExpectedRevisionId = string | null;
+
+type CreateWorkflowNodeTypeName =
+ | "AudienceFilter"
+ | "BranchNode"
+ | "ExperimentBranchNode"
+ | "TimerAction"
+ | "SendEmailAction"
+ | "VariantNode";
+
+type CreateWorkflowNodeParams =
+ | {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ insertMode: "between";
+ nodeTypeName: CreateWorkflowNodeTypeName;
+ fromNodeId: string;
+ toNodeId: string;
+ }
+ | {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ insertMode: "before";
+ nodeTypeName: CreateWorkflowNodeTypeName;
+ beforeNodeId: string;
+ };
+
+type CreatedWorkflowNode = WorkflowNodeWithRevision & {
+ /**
+ * Default child nodes created along with the requested node.
+ * BranchNode creation returns two AudienceFilter children.
+ * ExperimentBranchNode creation returns two regular VariantNode children
+ * and one control VariantNode.
+ */
+ createdChildNodes?: WorkflowNode[];
+};
+
+interface CreateWorkflowNodeResponse {
+ node: CreatedWorkflowNode;
+ workflow: SimplifiedWorkflow;
+}
+
+interface AddWorkflowBranchResponse {
+ node: WorkflowNodeWithRevision;
+ workflow: SimplifiedWorkflow;
+}
+
+interface WorkflowMailingListPreview {
+ status: "dryRun" | "queuedContactsFound";
+ mailingListId: string | null;
+ queuedContactCount: number;
+ queuedContactLimitReached: boolean;
+}
+
+interface WorkflowMailingListUpdatedResponse {
+ status: "updated";
+ mailingListId: string | null;
+ workflowRevisionId: string;
+ queuedContactCount: number;
+ queuedContactLimitReached: boolean;
+}
+
+type ChangeWorkflowMailingListResponse =
+ | WorkflowMailingListPreview
+ | WorkflowMailingListUpdatedResponse;
+
+interface WorkflowQueuedContactDeletePreview {
+ status: "dryRun" | "queuedContactsFound";
+ nodeIds: string[];
+ queuedContactCount: number;
+ queuedContactLimitReached: boolean;
+}
+
+interface WorkflowDeletedResponse {
+ status: "deleted";
+ nodeIds: string[];
+ workflowRevisionId: string;
+ queuedContactCount: number;
+ queuedContactLimitReached: boolean;
+}
+
+type DeleteWorkflowNodeResponse =
+ | WorkflowQueuedContactDeletePreview
+ | WorkflowDeletedResponse;
+
+type WorkflowContactPropertyComparisonOperator =
+ | "any"
+ | "contains"
+ | "not_contains"
+ | "empty"
+ | "not_empty"
+ | "equal"
+ | "not_equal"
+ | "greater_than"
+ | "less_than"
+ | "true"
+ | "false"
+ | "numeric_equal"
+ | "numeric_not_equal"
+ | "after"
+ | "before"
+ | "between";
+
+interface WorkflowContactPropertyComparison {
+ value: string | number | boolean;
+ operator: WorkflowContactPropertyComparisonOperator;
+}
+
+interface WorkflowContactPropertyQuery {
+ key: string;
+ is: WorkflowContactPropertyComparison;
+ was: WorkflowContactPropertyComparison;
+}
+
+type UpdateWorkflowNodePayload =
+ | { typeName: "SignupTrigger" }
+ | {
+ typeName?: "EventTrigger";
+ eventPatternId?: string | null;
+ eventName?: string | null;
+ reEligible?: boolean;
+ }
+ | {
+ typeName?: "ContactPropertyTrigger";
+ contactPropertyQuery?: WorkflowContactPropertyQuery;
+ reEligible?: boolean;
+ }
+ | {
+ typeName?: "AddToListTrigger";
+ reEligible?: boolean;
+ }
+ | {
+ audienceSegmentId?: string | null;
+ audienceFilter?: AudienceFilter;
+ appliesDownstream?: boolean;
+ }
+ | {
+ amount?: number;
+ unit?: "m" | "h" | "d";
+ }
+ | {
+ samplingRate?: number;
+ }
+ | {
+ isControl?: boolean;
+ };
+
interface EmailMessagePreviewResponse {
/** The ID of the email message the preview was sent for. */
id: string;
}
+type GuardianRuleName =
+ | "unsupportedContactProperties"
+ | "missingFallbackContactProperties"
+ | "unsupportedEventProperties"
+ | "missingFallbackEventProperties"
+ | "unsupportedDataVariables"
+ | "invalidCustomDataVariables"
+ | "missingRequiredDataVariables"
+ | "missingButtonHrefs"
+ | "invalidButtonHrefs"
+ | "shortenedYouTubeButtonHrefs"
+ | "missingLinkHrefs"
+ | "invalidLinkHrefs"
+ | "shortenedYouTubeLinkHrefs"
+ | "shortenedYouTubeImageHrefs"
+ | "emailWithoutMailtoButtonHrefs"
+ | "emailWithoutMailtoLinkHrefs"
+ | "emailWithoutMailtoImageHrefs"
+ | "bareArrayNodes"
+ | "missingSocialIconHrefs";
+
+interface GuardianRuleItem {
+ /** A human-readable label for the item (for example, link text or a property name). */
+ label: string;
+ /**
+ * Machine-readable identifier when the rule refers to a property or variable.
+ */
+ codeName?: string;
+}
+
+interface GuardianRule {
+ /** The identifier of the Guardian rule that fired. */
+ rule: GuardianRuleName;
+ /** A short summary of the rule. */
+ title: string;
+ /** A longer explanation of why the issue matters. */
+ description: string;
+ /** The specific elements that triggered the rule. */
+ items: GuardianRuleItem[];
+}
+
+interface EmailMessageGuardianResponse {
+ /** Validation errors. These must be resolved before the email can be published. */
+ errors: GuardianRule[];
+ /** Validation warnings. These are advisory and do not block publishing. */
+ warnings: GuardianRule[];
+}
+
interface ListTransactionalsResourceResponse {
pagination: PaginationData;
data: TransactionalEmailResource[];
@@ -472,6 +717,14 @@ interface ListThemesResponse {
type ThemeResponse = Theme;
+interface UpdateThemeResponse extends ThemeResponse {
+ /**
+ * The number of emails using this theme that are affected by the style change.
+ * `0` when only the name changed.
+ */
+ affectedEmailCount: number;
+}
+
interface Component {
id: string;
name: string;
@@ -485,10 +738,18 @@ interface ListComponentsResponse {
type ComponentResponse = Component;
+interface UpdateComponentResponse extends ComponentResponse {
+ /**
+ * The number of emails using this component that were updated by the body change.
+ * `0` when only the name changed.
+ */
+ affectedEmailCount: number;
+}
+
interface Campaign {
id: string;
name: string;
- status: string;
+ status: "Draft" | "Scheduled" | "Sending" | "Sent";
createdAt: string;
updatedAt: string;
emailMessageId: string | null;
@@ -1190,7 +1451,7 @@ class LoopsClient {
*
* @param {string} transactionalId The ID of the transactional email.
*
- * @see https://loops.so/docs/api-reference/ensure-transactional-email-draft
+ * @see https://loops.so/docs/api-reference/ensure-transactional-draft
*
* @returns {Object} Transactional email with draft (JSON)
*/
@@ -1224,7 +1485,7 @@ class LoopsClient {
/**
* List dedicated sending IP addresses.
*
- * @see https://loops.so/docs/api-reference/get-dedicated-sending-ips
+ * @see https://loops.so/docs/api-reference/dedicated-sending-ips
*
* @returns {string[]} List of IP addresses
*/
@@ -1279,6 +1540,40 @@ class LoopsClient {
});
}
+ /**
+ * Create an audience segment.
+ *
+ * @param {Object} params
+ * @param {string} params.name The name of the audience segment. Must be unique within the team.
+ * @param {string} [params.description] An optional description of the audience segment.
+ * @param {AudienceFilter} params.filter A tree of audience conditions combined with `match`.
+ *
+ * @see https://loops.so/docs/api-reference/create-audience-segment
+ *
+ * @returns {Object} Created audience segment (JSON)
+ */
+ async createAudienceSegment({
+ name,
+ description,
+ filter,
+ }: {
+ name: string;
+ description?: string;
+ filter: AudienceFilter;
+ }): Promise {
+ const payload: {
+ name: string;
+ description?: string;
+ filter: AudienceFilter;
+ } = { name, filter };
+ if (description !== undefined) payload.description = description;
+ return this._makeQuery({
+ path: "v1/audience-segments",
+ method: "POST",
+ payload,
+ });
+ }
+
/**
* List email themes.
*
@@ -1322,6 +1617,68 @@ class LoopsClient {
});
}
+ /**
+ * Create a theme.
+ *
+ * @param {Object} params
+ * @param {string} params.name The theme name.
+ * @param {ThemeStyles} [params.styles] Style attributes for the theme.
+ *
+ * @see https://loops.so/docs/api-reference/create-theme
+ *
+ * @returns {Object} Created theme (JSON)
+ */
+ async createTheme({
+ name,
+ styles,
+ }: {
+ name: string;
+ styles?: ThemeStyles;
+ }): Promise {
+ const payload: { name: string; styles?: ThemeStyles } = { name };
+ if (styles !== undefined) payload.styles = styles;
+ return this._makeQuery({
+ path: "v1/themes",
+ method: "POST",
+ payload,
+ });
+ }
+
+ /**
+ * Update a theme's name and/or styles.
+ *
+ * When `styles` change, the update cascades to every email using this theme.
+ * `affectedEmailCount` in the response reports how many emails were affected.
+ *
+ * @param {string} themeId The ID of the theme.
+ * @param {Object} params
+ * @param {string} [params.name] The theme name.
+ * @param {ThemeStyles} [params.styles] Style attributes for the theme.
+ *
+ * @see https://loops.so/docs/api-reference/update-theme
+ *
+ * @returns {Object} Updated theme (JSON)
+ */
+ async updateTheme(
+ themeId: string,
+ {
+ name,
+ styles,
+ }: {
+ name?: string;
+ styles?: ThemeStyles;
+ }
+ ): Promise {
+ const payload: { name?: string; styles?: ThemeStyles } = {};
+ if (name !== undefined) payload.name = name;
+ if (styles !== undefined) payload.styles = styles;
+ return this._makeQuery({
+ path: `v1/themes/${themeId}`,
+ method: "POST",
+ payload,
+ });
+ }
+
/**
* List email components.
*
@@ -1365,6 +1722,66 @@ class LoopsClient {
});
}
+ /**
+ * Create a component.
+ *
+ * @param {Object} params
+ * @param {string} params.name The component name.
+ * @param {string} params.lmx The component body as an LMX string.
+ *
+ * @see https://loops.so/docs/api-reference/create-component
+ *
+ * @returns {Object} Created component (JSON)
+ */
+ async createComponent({
+ name,
+ lmx,
+ }: {
+ name: string;
+ lmx: string;
+ }): Promise {
+ return this._makeQuery({
+ path: "v1/components",
+ method: "POST",
+ payload: { name, lmx },
+ });
+ }
+
+ /**
+ * Update a component's name and/or LMX body.
+ *
+ * When `lmx` changes, the update cascades to every email using this component.
+ * `affectedEmailCount` in the response reports how many were affected.
+ *
+ * @param {string} componentId The ID of the component.
+ * @param {Object} params
+ * @param {string} [params.name] The component name.
+ * @param {string} [params.lmx] The component body as an LMX string.
+ *
+ * @see https://loops.so/docs/api-reference/update-component
+ *
+ * @returns {Object} Updated component (JSON)
+ */
+ async updateComponent(
+ componentId: string,
+ {
+ name,
+ lmx,
+ }: {
+ name?: string;
+ lmx?: string;
+ }
+ ): Promise {
+ const payload: { name?: string; lmx?: string } = {};
+ if (name !== undefined) payload.name = name;
+ if (lmx !== undefined) payload.lmx = lmx;
+ return this._makeQuery({
+ path: `v1/components/${componentId}`,
+ method: "POST",
+ payload,
+ });
+ }
+
/**
* List campaigns.
*
@@ -1644,7 +2061,7 @@ class LoopsClient {
* @param {Record} [params.eventProperties] Event property values to render.
* @param {Record} [params.dataVariables] Transactional data variables to render.
*
- * @see https://loops.so/docs/api-reference/send-email-message-preview
+ * @see https://loops.so/docs/api-reference/preview-email-message
*
* @returns {Object} Preview confirmation (JSON)
*/
@@ -1682,6 +2099,84 @@ class LoopsClient {
});
}
+ /**
+ * Run Guardian checks on an email message.
+ *
+ * Validates content against Guardian rules and returns errors and warnings.
+ * Errors must be resolved before the email can be published; warnings are advisory.
+ *
+ * @param {string} emailMessageId The ID of the email message.
+ *
+ * @see https://loops.so/docs/api-reference/run-guardian-checks
+ *
+ * @returns {Object} Guardian errors and warnings (JSON)
+ */
+ async runEmailMessageGuardian(
+ emailMessageId: string
+ ): Promise {
+ return this._makeQuery({
+ path: `v1/email-messages/${emailMessageId}/guardian`,
+ });
+ }
+
+ /**
+ * List event patterns available to workflow event trigger nodes.
+ *
+ * @param {Object} params
+ * @param {number} [params.perPage] How many results to return in each request. Must be between 10 and 50. Defaults to 20.
+ * @param {string} [params.cursor] A cursor, to return a specific page of results.
+ *
+ * @see https://loops.so/docs/api-reference/list-event-patterns
+ *
+ * @returns {Object} List of event patterns (JSON)
+ */
+ async listEventPatterns({
+ perPage,
+ cursor,
+ }: {
+ perPage?: number;
+ cursor?: string;
+ } = {}): Promise {
+ const params: { perPage: string; cursor?: string } = {
+ perPage: (perPage || 20).toString(),
+ };
+ if (cursor) params["cursor"] = cursor;
+ return this._makeQuery({
+ path: "v1/event-patterns",
+ params,
+ });
+ }
+
+ /**
+ * Get an event pattern by ID.
+ *
+ * @param {string} eventPatternId The ID of the event pattern.
+ *
+ * @see https://loops.so/docs/api-reference/get-event-pattern
+ *
+ * @returns {Object} Event pattern (JSON)
+ */
+ async getEventPattern(eventPatternId: string): Promise {
+ return this._makeQuery({
+ path: `v1/event-patterns/${eventPatternId}`,
+ });
+ }
+
+ /**
+ * Get an event pattern by event name.
+ *
+ * @param {string} eventName The name of the event pattern.
+ *
+ * @see https://loops.so/docs/api-reference/get-event-pattern-by-name
+ *
+ * @returns {Object} Event pattern (JSON)
+ */
+ async getEventPatternByName(eventName: string): Promise {
+ return this._makeQuery({
+ path: `v1/event-patterns/by-name/${encodeURIComponent(eventName)}`,
+ });
+ }
+
/**
* List workflows.
*
@@ -1710,6 +2205,41 @@ class LoopsClient {
});
}
+ /**
+ * Create a draft workflow with a blank trigger and exit node.
+ *
+ * @param {Object} params
+ * @param {string} params.name The name of the workflow.
+ * @param {string} [params.description] The description of the workflow.
+ * @param {string | null} [params.mailingListId] The ID of a mailing list the workflow sends to.
+ *
+ * @see https://loops.so/docs/api-reference/create-workflow
+ *
+ * @returns {Object} Created workflow (JSON)
+ */
+ async createWorkflow({
+ name,
+ description,
+ mailingListId,
+ }: {
+ name: string;
+ description?: string;
+ mailingListId?: string | null;
+ }): Promise {
+ const payload: {
+ name: string;
+ description?: string;
+ mailingListId?: string | null;
+ } = { name };
+ if (description !== undefined) payload.description = description;
+ if (mailingListId !== undefined) payload.mailingListId = mailingListId;
+ return this._makeQuery({
+ path: "v1/workflows",
+ method: "POST",
+ payload,
+ });
+ }
+
/**
* Get a workflow by ID.
*
@@ -1719,12 +2249,130 @@ class LoopsClient {
*
* @returns {Object} Workflow graph (JSON)
*/
- async getWorkflow(workflowId: string): Promise {
+ async getWorkflow(
+ workflowId: string
+ ): Promise {
return this._makeQuery({
path: `v1/workflows/${workflowId}`,
});
}
+ /**
+ * Update a workflow's display properties.
+ *
+ * At least one of `name` or `description` must be provided. To change the
+ * mailing list, use `changeWorkflowMailingList()`.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @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.name] The updated workflow name.
+ * @param {string} [params.description] The updated workflow description.
+ *
+ * @see https://loops.so/docs/api-reference/update-workflow
+ *
+ * @returns {Object} Updated workflow (JSON)
+ */
+ async updateWorkflow(
+ workflowId: string,
+ {
+ expectedRevisionId,
+ name,
+ description,
+ }: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ name?: string;
+ description?: string;
+ }
+ ): Promise {
+ const payload: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ name?: string;
+ description?: string;
+ } = { expectedRevisionId };
+ if (name !== undefined) payload.name = name;
+ if (description !== undefined) payload.description = description;
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}`,
+ method: "POST",
+ payload,
+ });
+ }
+
+ /**
+ * Dry run or apply a workflow mailing list change.
+ *
+ * If queued contacts would be removed, Loops returns
+ * `"status": "queuedContactsFound"` instead of updating. Retry with
+ * `queuedContactPolicy: "discard"` to apply the change.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @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 | null} params.mailingListId The mailing list to use, or `null` to clear it.
+ * @param {boolean} [params.dryRun] If `true`, validate without modifying the workflow.
+ * @param {"fail" | "discard"} [params.queuedContactPolicy] How to handle queued contacts that would be removed.
+ *
+ * @see https://loops.so/docs/api-reference/change-workflow-mailing-list
+ *
+ * @returns {Object} Preview or updated mailing list result (JSON)
+ */
+ async changeWorkflowMailingList(
+ workflowId: string,
+ {
+ expectedRevisionId,
+ mailingListId,
+ dryRun,
+ queuedContactPolicy,
+ }: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ mailingListId: string | null;
+ dryRun?: boolean;
+ queuedContactPolicy?: WorkflowQueuedContactPolicy;
+ }
+ ): Promise {
+ const payload: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ mailingListId: string | null;
+ dryRun?: boolean;
+ queuedContactPolicy?: WorkflowQueuedContactPolicy;
+ } = { expectedRevisionId, mailingListId };
+ if (dryRun !== undefined) payload.dryRun = dryRun;
+ if (queuedContactPolicy !== undefined) {
+ payload.queuedContactPolicy = queuedContactPolicy;
+ }
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}/mailing-list`,
+ method: "POST",
+ payload,
+ });
+ }
+
+ /**
+ * 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()`.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @param {Object} params Create parameters including `insertMode` and revision.
+ *
+ * @see https://loops.so/docs/api-reference/create-workflow-node
+ *
+ * @returns {Object} Created node and workflow (JSON)
+ */
+ async createWorkflowNode(
+ workflowId: string,
+ params: CreateWorkflowNodeParams
+ ): Promise {
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}/nodes`,
+ method: "POST",
+ payload: params,
+ });
+ }
+
/**
* Get a workflow node by ID.
*
@@ -1735,9 +2383,168 @@ class LoopsClient {
*
* @returns {Object} Workflow node (JSON)
*/
- async getWorkflowNode(workflowId: string, nodeId: string): Promise {
+ async getWorkflowNode(
+ workflowId: string,
+ nodeId: string
+ ): Promise {
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}/nodes/${nodeId}`,
+ });
+ }
+
+ /**
+ * Update workflow-node-owned fields for a single node.
+ *
+ * Shared resources such as email messages and audience segments should be
+ * updated through their own APIs.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @param {string} nodeId The ID of the 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 {UpdateWorkflowNodePayload} params.payload Node-type-specific fields to update.
+ *
+ * @see https://loops.so/docs/api-reference/update-workflow-node
+ *
+ * @returns {Object} Updated workflow node (JSON)
+ */
+ async updateWorkflowNode(
+ workflowId: string,
+ nodeId: string,
+ {
+ expectedRevisionId,
+ payload,
+ }: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ payload: UpdateWorkflowNodePayload;
+ }
+ ): Promise {
return this._makeQuery({
path: `v1/workflows/${workflowId}/nodes/${nodeId}`,
+ method: "POST",
+ payload: { expectedRevisionId, payload },
+ });
+ }
+
+ /**
+ * Delete a single workflow node.
+ *
+ * If contacts are queued at the node, Loops returns
+ * `"status": "queuedContactsFound"` instead of deleting. Retry with
+ * `queuedContactPolicy: "discard"` to delete the node.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @param {string} nodeId The ID of the 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 {boolean} [params.dryRun] If `true`, validate without modifying the workflow.
+ * @param {"fail" | "discard"} [params.queuedContactPolicy] How to handle queued contacts.
+ *
+ * @see https://loops.so/docs/api-reference/delete-workflow-node
+ *
+ * @returns {Object} Preview or delete result (JSON)
+ */
+ async deleteWorkflowNode(
+ workflowId: string,
+ nodeId: string,
+ {
+ expectedRevisionId,
+ dryRun,
+ queuedContactPolicy,
+ }: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ dryRun?: boolean;
+ queuedContactPolicy?: WorkflowQueuedContactPolicy;
+ }
+ ): Promise {
+ const payload: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ dryRun?: boolean;
+ queuedContactPolicy?: WorkflowQueuedContactPolicy;
+ } = { expectedRevisionId };
+ if (dryRun !== undefined) payload.dryRun = dryRun;
+ if (queuedContactPolicy !== undefined) {
+ payload.queuedContactPolicy = queuedContactPolicy;
+ }
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}/nodes/${nodeId}`,
+ method: "DELETE",
+ payload,
+ });
+ }
+
+ /**
+ * Add a branch and a child node under an existing Branch or Experiment node.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @param {string} nodeId The ID of the Branch or Experiment 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.
+ *
+ * @see https://loops.so/docs/api-reference/add-workflow-branch
+ *
+ * @returns {Object} Created child node and workflow (JSON)
+ */
+ async addWorkflowBranch(
+ workflowId: string,
+ nodeId: string,
+ {
+ expectedRevisionId,
+ }: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ }
+ ): Promise {
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}/nodes/${nodeId}/add-branch`,
+ method: "POST",
+ payload: { expectedRevisionId },
+ });
+ }
+
+ /**
+ * Delete a node and its downstream subtree.
+ *
+ * If contacts are queued at any node that would be deleted, Loops returns
+ * `"status": "queuedContactsFound"` instead of deleting. Retry with
+ * `queuedContactPolicy: "discard"` to delete.
+ *
+ * @param {string} workflowId The ID of the workflow.
+ * @param {string} nodeId The ID of the 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 {boolean} [params.dryRun] If `true`, validate without modifying the workflow.
+ * @param {"fail" | "discard"} [params.queuedContactPolicy] How to handle queued contacts.
+ *
+ * @see https://loops.so/docs/api-reference/delete-workflow-nodes
+ *
+ * @returns {Object} Preview or delete result (JSON)
+ */
+ async deleteWorkflowNodesRecursive(
+ workflowId: string,
+ nodeId: string,
+ {
+ expectedRevisionId,
+ dryRun,
+ queuedContactPolicy,
+ }: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ dryRun?: boolean;
+ queuedContactPolicy?: WorkflowQueuedContactPolicy;
+ }
+ ): Promise {
+ const payload: {
+ expectedRevisionId: WorkflowExpectedRevisionId;
+ dryRun?: boolean;
+ queuedContactPolicy?: WorkflowQueuedContactPolicy;
+ } = { expectedRevisionId };
+ if (dryRun !== undefined) payload.dryRun = dryRun;
+ if (queuedContactPolicy !== undefined) {
+ payload.queuedContactPolicy = queuedContactPolicy;
+ }
+ return this._makeQuery({
+ path: `v1/workflows/${workflowId}/nodes/${nodeId}/recursive`,
+ method: "DELETE",
+ payload,
});
}
@@ -2026,9 +2833,11 @@ export {
Theme,
ListThemesResponse,
ThemeResponse,
+ UpdateThemeResponse,
Component,
ListComponentsResponse,
ComponentResponse,
+ UpdateComponentResponse,
Group,
ListGroupsResponse,
AudienceFilterBetweenValue,
@@ -2041,12 +2850,40 @@ export {
CampaignSchedulingRequest,
AudienceSegment,
ListAudienceSegmentsResponse,
+ IncomingWebhookPlatform,
+ EventPatternSummary,
+ WorkflowEventProperty,
+ EventPattern,
+ ListEventPatternsResponse,
WorkflowSummary,
ListWorkflowsResponse,
+ WorkflowStatus,
SimplifiedWorkflowNode,
SimplifiedWorkflow,
+ WorkflowExpectedRevisionId,
WorkflowNode,
+ WorkflowNodeWithRevision,
+ WorkflowQueuedContactPolicy,
+ CreateWorkflowNodeTypeName,
+ CreateWorkflowNodeParams,
+ CreatedWorkflowNode,
+ CreateWorkflowNodeResponse,
+ AddWorkflowBranchResponse,
+ WorkflowMailingListPreview,
+ WorkflowMailingListUpdatedResponse,
+ ChangeWorkflowMailingListResponse,
+ WorkflowQueuedContactDeletePreview,
+ WorkflowDeletedResponse,
+ DeleteWorkflowNodeResponse,
+ WorkflowContactPropertyComparisonOperator,
+ WorkflowContactPropertyComparison,
+ WorkflowContactPropertyQuery,
+ UpdateWorkflowNodePayload,
EmailMessagePreviewResponse,
+ GuardianRuleName,
+ GuardianRuleItem,
+ GuardianRule,
+ EmailMessageGuardianResponse,
Campaign,
CampaignListItem,
ListCampaignsResponse,
diff --git a/tsconfig.json b/tsconfig.json
index 7323402..06fa51a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,7 +4,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"strict": true,
- "target": "ESNext"
+ "target": "ESNext",
+ "types": ["jest", "node"]
},
"include": ["./src/**/*.ts"]
}
\ No newline at end of file