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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [updateContact()](#updatecontact)
- [findContact()](#findcontact)
- [deleteContact()](#deletecontact)
- [checkContactSuppression()](#checkcontactsuppression)
- [removeContactSuppression()](#removecontactsuppression)
- [createContactProperty()](#createcontactproperty)
- [getContactProperties()](#getcontactproperties)
- [getMailingLists()](#getmailinglists)
Expand Down Expand Up @@ -363,6 +365,122 @@ HTTP 404 Not Found

---

### checkContactSuppression()

Check whether a contact is suppressed, either by email address or `userId`.

[API Reference](https://loops.so/docs/api-reference/check-contact-suppression)

#### Parameters

You must use one parameter in the request.

| Name | Type | Required | Notes |
| -------- | ------ | -------- | ----- |
| `email` | string | No | |
| `userId` | string | No | |

#### Example

```javascript
const resp = await loops.checkContactSuppression({ email: "hello@gmail.com" });

const resp = await loops.checkContactSuppression({ userId: "12345" });
```

#### Response

```json
{
"contact": {
"id": "cll6b3i8901a9jx0oyktl2m4u",
"email": "adam@loops.so",
"userId": null
},
"isSuppressed": true,
"removalQuota": {
"limit": 100,
"remaining": 4
}
}
```

Error handling is done through the `APIError` class, which provides `statusCode` and `json` properties containing the API's error response details. For implementation examples, see the [Usage section](#usage).

```json
HTTP 400 Bad Request
{
"success": false,
"message": "An email or userId is required."
}
```

```json
HTTP 404 Not Found
{
"success": false,
"message": "This contact was not found."
}
```

---

### removeContactSuppression()

Remove suppression for a contact, either by email address or `userId`.

[API Reference](https://loops.so/docs/api-reference/remove-contact-suppression)

#### Parameters

You must use one parameter in the request.

| Name | Type | Required | Notes |
| -------- | ------ | -------- | ----- |
| `email` | string | No | |
| `userId` | string | No | |

#### Example

```javascript
const resp = await loops.removeContactSuppression({ email: "hello@gmail.com" });

const resp = await loops.removeContactSuppression({ userId: "12345" });
```

#### Response

```json
{
"success": true,
"message": "Email removed from suppression list.",
"removalQuota": {
"limit": 100,
"remaining": 4
}
}
```

Error handling is done through the `APIError` class, which provides `statusCode` and `json` properties containing the API's error response details. For implementation examples, see the [Usage section](#usage).

```json
HTTP 400 Bad Request
{
"success": false,
"message": "This contact is not suppressed."
}
```

```json
HTTP 404 Not Found
{
"success": false,
"message": "This contact was not found."
}
```

---

### createContactProperty()

Create a new contact property.
Expand Down Expand Up @@ -770,6 +888,7 @@ const resp = await loops.getTransactionalEmails({ perPage: 15 });

## Version history

- `v6.3.0` (Apr 8, 2026) - Added [`checkContactSuppression()`](#checkcontactsuppression) and [`removeContactSuppression()`](#removecontactsuppression) methods.
- `v6.2.0` (Feb 9, 2026) - Support for the new arrays feature in sendTransactionalEmail.
- `v6.1.2` (Jan 29, 2026) - Added `rawBody` to `APIError` in the case no JSON is received from the server (thanks to [@leipert](https://github.com/leipert)).
- `v6.0.1` (Oct 15, 2025) - Added `optInStatus` to contact object in [`findContact()`](#findcontact) for the new double opt-in feature.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "loops",
"version": "6.2.1",
"version": "6.3.0",
"author": "Dan Rowden <dan@loops.so>",
"license": "MIT",
"main": "./dist/index.cjs",
Expand Down
150 changes: 150 additions & 0 deletions src/__tests__/LoopsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,156 @@ describe("LoopsClient", () => {
});
});

describe("checkContactSuppression", () => {
it("should check suppression status by email", async () => {
const email = "test@example.com";
const mockResponse = {
contact: {
id: "cll6b3i8901a9jx0oyktl2m4u",
email,
userId: null,
},
isSuppressed: true,
removalQuota: {
limit: 100,
remaining: 10,
},
};

global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve(JSON.stringify(mockResponse)),
});

const result = await client.checkContactSuppression({ email });

expect(result).toEqual(mockResponse);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("v1/contacts/suppression?email=test%40example.com"),
expect.objectContaining({
method: "GET",
})
);
});

it("should check suppression status by userId", async () => {
const userId = "user_123";
const mockResponse = {
contact: {
id: "cll6b3i8901a9jx0oyktl2m4u",
email: "test@example.com",
userId,
},
isSuppressed: false,
removalQuota: {
limit: 100,
remaining: 99,
},
};

global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve(JSON.stringify(mockResponse)),
});

const result = await client.checkContactSuppression({ userId });

expect(result).toEqual(mockResponse);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("v1/contacts/suppression?userId=user_123"),
expect.objectContaining({
method: "GET",
})
);
});

it("should throw error when both email and userId are provided", async () => {
await expect(
client.checkContactSuppression({
email: "test@example.com",
userId: "user_123",
})
).rejects.toThrow(ValidationError);
});

it("should throw error when neither email nor userId is provided", async () => {
await expect(client.checkContactSuppression({})).rejects.toThrow(
ValidationError
);
});
});

describe("removeContactSuppression", () => {
it("should remove suppression by email", async () => {
const email = "test@example.com";
const mockResponse = {
success: true,
message: "Email removed from suppression list.",
removalQuota: {
limit: 100,
remaining: 9,
},
};

global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve(JSON.stringify(mockResponse)),
});

const result = await client.removeContactSuppression({ email });

expect(result).toEqual(mockResponse);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("v1/contacts/suppression?email=test%40example.com"),
expect.objectContaining({
method: "DELETE",
})
);
});

it("should remove suppression by userId", async () => {
const userId = "user_123";
const mockResponse = {
success: true,
message: "User removed from suppression list.",
removalQuota: {
limit: 100,
remaining: 8,
},
};

global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve(JSON.stringify(mockResponse)),
});

const result = await client.removeContactSuppression({ userId });

expect(result).toEqual(mockResponse);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("v1/contacts/suppression?userId=user_123"),
expect.objectContaining({
method: "DELETE",
})
);
});

it("should throw error when both email and userId are provided", async () => {
await expect(
client.removeContactSuppression({
email: "test@example.com",
userId: "user_123",
})
).rejects.toThrow(ValidationError);
});

it("should throw error when neither email nor userId is provided", async () => {
await expect(client.removeContactSuppression({})).rejects.toThrow(
ValidationError
);
});
});

describe("createContactProperty", () => {
it("should create contact property successfully", async () => {
const name = "customField";
Expand Down
Loading