diff --git a/docs/app.md b/docs/app.md
index 549761e..84c5128 100644
--- a/docs/app.md
+++ b/docs/app.md
@@ -1765,3 +1765,462 @@ api.deleteAssetFolder(5);
#### Options
- **folderId**: The folder's numeric id (required)
+
+## Collections
+
+Manage [data collections](https://customer.io/docs/journeys/collections/) — reusable datasets you can reference from messages. Collection ids are **integers**.
+
+### api.listCollections()
+
+List the collections in your workspace.
+
+```javascript
+api.listCollections();
+```
+
+### api.createCollection(collection)
+
+Create a collection. Provide inline `data` **or** a source `url`, not both.
+
+```javascript
+api.createCollection({
+ name: "Plans",
+ data: [
+ { tier: "free", price: 0 },
+ { tier: "pro", price: 20 },
+ ],
+});
+```
+
+#### Options
+
+- **collection**: The collection definition
+ - _name_: The collection's name (required)
+ - _data_: An array of row objects (mutually exclusive with `url`)
+ - _url_: A source URL to import rows from — CSV/JSON/Google Sheet (mutually exclusive with `data`)
+
+### api.getCollection(collectionId)
+
+Get a single collection's metadata (`name`, `schema`, `rows`, `bytes`, timestamps). Use `getCollectionContent` for the rows themselves.
+
+```javascript
+api.getCollection(9);
+```
+
+#### Options
+
+- **collectionId**: The collection's numeric id (required)
+
+### api.updateCollection(collectionId, updates)
+
+Update a collection. Any subset of fields may be provided; `data` and `url` are mutually exclusive.
+
+```javascript
+api.updateCollection(9, { name: "Renamed" });
+```
+
+#### Options
+
+- **collectionId**: The collection's numeric id (required)
+- **updates**: Object with any of `name`, `data`, `url`
+
+### api.deleteCollection(collectionId)
+
+Delete a collection. Returns no content on success. Fails if the collection is still referenced by a campaign.
+
+```javascript
+api.deleteCollection(9);
+```
+
+#### Options
+
+- **collectionId**: The collection's numeric id (required)
+
+### api.getCollectionContent(collectionId)
+
+Get a collection's content — the full array of data rows.
+
+```javascript
+api.getCollectionContent(9);
+```
+
+#### Options
+
+- **collectionId**: The collection's numeric id (required)
+
+### api.updateCollectionContent(collectionId, content)
+
+Replace a collection's content with a new array of data rows.
+
+```javascript
+api.updateCollectionContent(9, [
+ { tier: "free", price: 0 },
+ { tier: "pro", price: 20 },
+]);
+```
+
+#### Options
+
+- **collectionId**: The collection's numeric id (required)
+- **content**: An array of row objects (required)
+
+## Deliverability
+
+### ESP suppressions
+
+Manage the email-provider suppression lists. `suppressionType` is one of `"blocks"`, `"bounces"`, `"spam_reports"`, or `"invalid_emails"`.
+
+### api.searchSuppression(email)
+
+Search every suppression category for an email address.
+
+```javascript
+api.searchSuppression("person@example.com");
+```
+
+#### Options
+
+- **email**: The email address to search for (required)
+
+### api.getSuppressions(suppressionType, options)
+
+List suppressions in a category (offset-based pagination).
+
+```javascript
+api.getSuppressions("bounces", { limit: 50, offset: 0 });
+```
+
+#### Options
+
+- **suppressionType**: One of `"blocks"` / `"bounces"` / `"spam_reports"` / `"invalid_emails"` (required)
+- **options**: Object (optional) — `limit` (1–1000, default 100), `offset` (default 0), `email`, `domain`
+
+### api.getDomainSuppressions(domainName, suppressionType, options)
+
+List suppressions in a category for a single sending domain (cursor-based pagination — preferred for large volumes).
+
+```javascript
+api.getDomainSuppressions("mail.example.com", "bounces", { limit: 100, start: cursor });
+```
+
+#### Options
+
+- **domainName**: The sending domain (required)
+- **suppressionType**: One of `"blocks"` / `"bounces"` / `"spam_reports"` / `"invalid_emails"` (required)
+- **options**: Object (optional) — `limit` (1–1000, default 100), `email`, `start` (pagination cursor from a previous page's `next`)
+
+### api.createSuppression(suppressionType, email)
+
+Add an email address to a suppression category.
+
+```javascript
+api.createSuppression("bounces", "person@example.com");
+```
+
+#### Options
+
+- **suppressionType**: One of `"blocks"` / `"bounces"` / `"spam_reports"` / `"invalid_emails"` (required)
+- **email**: The email address to suppress (required)
+
+### api.deleteSuppression(suppressionType, email)
+
+Remove an email address from a suppression category. Returns no content on success.
+
+```javascript
+api.deleteSuppression("bounces", "person@example.com");
+```
+
+#### Options
+
+- **suppressionType**: One of `"blocks"` / `"bounces"` / `"spam_reports"` / `"invalid_emails"` (required)
+- **email**: The email address to unsuppress (required)
+
+### Reporting webhooks
+
+Manage [reporting webhooks](https://customer.io/docs/journeys/webhooks/) that POST message events to your endpoint. Webhook ids are **integers**.
+
+### api.listReportingWebhooks()
+
+List the reporting webhooks in your workspace.
+
+```javascript
+api.listReportingWebhooks();
+```
+
+### api.createReportingWebhook(webhook)
+
+Create a reporting webhook.
+
+```javascript
+api.createReportingWebhook({
+ endpoint: "https://example.com/cio-events",
+ events: ["sent", "delivered", "opened", "clicked", "bounced"],
+ name: "Production events",
+ with_content: false,
+});
+```
+
+#### Options
+
+- **webhook**: The webhook definition
+ - _endpoint_: The destination URL that events are POSTed to (required)
+ - _events_: The event types to send (e.g. `drafted`, `sent`, `delivered`, `opened`, `clicked`, `bounced`, `converted`)
+ - _name_: Display name, ≤190 characters
+ - _full_resolution_: Send an event for every occurrence rather than de-duplicating
+ - _with_content_: Include message content in the payloads
+ - _disabled_: Create the webhook in a disabled state
+
+### api.getReportingWebhook(webhookId)
+
+Get a single reporting webhook.
+
+```javascript
+api.getReportingWebhook(4);
+```
+
+#### Options
+
+- **webhookId**: The webhook's numeric id (required)
+
+### api.updateReportingWebhook(webhookId, updates)
+
+Update a reporting webhook. Any subset of fields may be provided.
+
+```javascript
+api.updateReportingWebhook(4, { disabled: true });
+```
+
+#### Options
+
+- **webhookId**: The webhook's numeric id (required)
+- **updates**: Object with any of `endpoint`, `events`, `name`, `full_resolution`, `with_content`, `disabled`
+
+### api.deleteReportingWebhook(webhookId)
+
+Delete a reporting webhook. Returns no content on success.
+
+```javascript
+api.deleteReportingWebhook(4);
+```
+
+#### Options
+
+- **webhookId**: The webhook's numeric id (required)
+
+## Content & sender utilities
+
+### Snippets
+
+Manage [snippets](https://customer.io/docs/journeys/snippets/) — reusable content blocks referenced from messages. Snippets are identified by `name`.
+
+### api.getSnippets()
+
+List the snippets in your workspace.
+
+```javascript
+api.getSnippets();
+```
+
+### api.createSnippet(snippet)
+
+Create a snippet.
+
+```javascript
+api.createSnippet({ name: "footer", value: "
© 2026 Example
" });
+```
+
+#### Options
+
+- **snippet**: The snippet definition
+ - _name_: The snippet's unique name/key (required)
+ - _value_: The snippet's value; may contain Liquid (required)
+
+### api.updateSnippet(snippet)
+
+Create or update a snippet (upsert by name).
+
+```javascript
+api.updateSnippet({ name: "footer", value: "© 2027 Example
" });
+```
+
+#### Options
+
+- **snippet**: The snippet definition — `name` and `value` (both required)
+
+### api.deleteSnippet(name)
+
+Delete a snippet by name. Returns no content on success. Fails if the snippet is still in use.
+
+```javascript
+api.deleteSnippet("footer");
+```
+
+#### Options
+
+- **name**: The snippet's name (required)
+
+### Sender identities
+
+### api.getSenderIdentities(options)
+
+List the sender identities in your workspace.
+
+```javascript
+api.getSenderIdentities({ limit: 50, sort: "asc" });
+```
+
+#### Options
+
+- **options**: Object (optional) — `start`, `limit`, `sort` ("asc" / "desc"), `hidden` (omit for all, `true`/`false` to filter)
+
+### api.getSenderIdentity(senderId)
+
+Get a single sender identity.
+
+```javascript
+api.getSenderIdentity(12);
+```
+
+#### Options
+
+- **senderId**: The sender identity's numeric id (required)
+
+### api.getSenderIdentityUsedBy(senderId)
+
+List the campaigns and newsletters that use a sender identity.
+
+```javascript
+api.getSenderIdentityUsedBy(12);
+```
+
+#### Options
+
+- **senderId**: The sender identity's numeric id (required)
+
+### Messages
+
+Look up sent messages (deliveries) across your workspace. For a single person's messages, use [`getCustomerMessages`](#apigetcustomermessagescustomerid-options).
+
+### api.getMessages(options)
+
+List sent messages.
+
+```javascript
+api.getMessages({ metric: "delivered", type: "email", limit: 50 });
+```
+
+#### Options
+
+- **options**: Object (optional)
+ - _start_: Pagination cursor from a previous page's `next`
+ - _limit_: Maximum number of results
+ - _drafts_: Return only drafts (`true`) or exclude them (default)
+ - _metric_: Filter to a delivery metric (e.g. `delivered`, `opened`, `bounced`)
+ - _type_: Scope to a channel ("email" / "webhook" / "twilio" / "whatsapp" / "slack" / "push" / "in_app")
+ - _campaign_id_ / _action_id_ / _newsletter_id_ / _transactional_id_ / _trigger_id_ / _template_id_ / _content_id_: Scope to a single resource
+ - _start_ts_ / _end_ts_: Unix timestamp bounds (seconds)
+ - _associations_: Include related campaigns/actions/newsletters/contents
+ - _get_tracked_responses_: Include tracked responses on each delivery
+
+### api.getMessage(messageId, options)
+
+Get a single sent message.
+
+```javascript
+api.getMessage("RPCImftJDcAAAAd...", { archived_message: true });
+```
+
+#### Options
+
+- **messageId**: The delivery id (`CIO-Delivery-ID`) (required)
+- **options**: Object (optional) — `archived_message`, `associations`, `get_tracked_responses`
+
+### api.getArchivedMessage(messageId)
+
+Get the archived content of a single sent message.
+
+```javascript
+api.getArchivedMessage("RPCImftJDcAAAAd...");
+```
+
+#### Options
+
+- **messageId**: The delivery id (`CIO-Delivery-ID`) (required)
+
+## Imports, data index & workspace info
+
+### api.createImport(importData)
+
+Start a [CSV import](https://customer.io/docs/api/app/#operation/createImports). The CSV is loaded from a hosted URL you provide as `data_file_url`.
+
+```javascript
+api.createImport({
+ data_file_url: "https://example.com/people.csv",
+ type: "people",
+ identifier: "email",
+ name: "Q3 signups",
+});
+```
+
+#### Options
+
+- **importData**: The import definition
+ - _data_file_url_: URL of the CSV file to import (required)
+ - _type_: `"people"` / `"event"` / `"object"` / `"relationship"` (required)
+ - _identifier_: Which column keys the rows — `"id"`/`"email"` for people & events; `"id"`/`"email"`/`"cio_id"` for relationships
+ - _object_type_id_: Required when `type` is `"object"`
+ - _name_: Display name (defaults to the filename)
+ - _description_: Description of the import
+ - _people_to_process_ / _data_to_process_: `"all"` / `"only_existing"` / `"only_new"` (mutually exclusive)
+
+### api.getImport(importId)
+
+Get the status of an import.
+
+```javascript
+api.getImport(15);
+```
+
+#### Options
+
+- **importId**: The import's numeric id (required)
+
+### api.batchUpdateAttributes(attributes)
+
+Batch-update attribute metadata (descriptions, etc.) — up to 100 at a time.
+
+```javascript
+api.batchUpdateAttributes([{ name: "plan", description: "Subscription plan" }]);
+```
+
+#### Options
+
+- **attributes**: A non-empty array of attribute updates (max 100). Each requires a `name`; may also include `description`, `object_type_id`, `is_relationship`, `event_name`, `privacy_level`
+
+### api.batchUpdateEvents(events)
+
+Batch-update event metadata — up to 100 at a time.
+
+```javascript
+api.batchUpdateEvents([{ name: "purchase", description: "Completed a purchase" }]);
+```
+
+#### Options
+
+- **events**: A non-empty array of event updates (max 100). Each requires a `name`; may also include `description`
+
+### api.listWorkspaces()
+
+List the workspaces (environments) in your account, with usage counts.
+
+```javascript
+api.listWorkspaces();
+```
+
+### api.getIpAddresses()
+
+List the Customer.io egress IP addresses (for allowlisting).
+
+```javascript
+api.getIpAddresses();
+```
diff --git a/lib/api.ts b/lib/api.ts
index cd7a808..777f2f7 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -493,6 +493,191 @@ export type AssetFolderUpdate = {
parent_folder_id?: number | null;
};
+/** A single row of collection data — an arbitrary flat object. */
+export type CollectionRow = Record;
+
+/**
+ * Definition for creating a collection via {@link APIClient.createCollection}.
+ * Provide inline `data` **or** a source `url`, not both.
+ */
+export type CollectionInput = {
+ /** The collection's name (required). */
+ name: string;
+ /** Inline row data. Mutually exclusive with `url`. */
+ data?: CollectionRow[];
+ /** Source URL to import rows from (CSV/JSON/Google Sheet). Mutually exclusive with `data`. */
+ url?: string;
+};
+
+/**
+ * Fields for updating a collection via {@link APIClient.updateCollection}. Any subset may be
+ * provided; `data` and `url` are mutually exclusive.
+ */
+export type CollectionUpdate = {
+ name?: string;
+ data?: CollectionRow[];
+ url?: string;
+};
+
+/** An email-suppression category for the ESP (deliverability) endpoints. */
+export type SuppressionType = 'blocks' | 'bounces' | 'spam_reports' | 'invalid_emails';
+
+/** Options for {@link APIClient.getSuppressions} (offset-based). */
+export type SuppressionsOptions = {
+ /** Page size, 1–1000. Defaults to 100. */
+ limit?: number;
+ /** Number of records to skip. Defaults to 0. */
+ offset?: number;
+ /** Filter to a single email address. */
+ email?: string;
+ /** Filter to a single sending domain. */
+ domain?: string;
+};
+
+/** Options for {@link APIClient.getDomainSuppressions} (cursor-based). */
+export type DomainSuppressionsOptions = {
+ /** Page size, 1–1000. Defaults to 100. */
+ limit?: number;
+ /** Filter to a single email address. */
+ email?: string;
+ /** Pagination cursor returned as `next` by a previous page. */
+ start?: string;
+};
+
+/** Definition for creating a reporting webhook via {@link APIClient.createReportingWebhook}. */
+export type ReportingWebhookInput = {
+ /** The destination URL that events are POSTed to (required). */
+ endpoint: string;
+ /** The event types to send (e.g. `drafted`, `sent`, `delivered`, `opened`, `clicked`, `bounced`). */
+ events: string[];
+ /** Display name (≤190 characters). */
+ name?: string;
+ /** When `true`, send an event for every occurrence rather than de-duplicating. */
+ full_resolution?: boolean;
+ /** When `true`, include message content in the event payloads. */
+ with_content?: boolean;
+ /** When `true`, create the webhook in a disabled state. */
+ disabled?: boolean;
+};
+
+/** Fields for updating a reporting webhook via {@link APIClient.updateReportingWebhook}. Any subset may be provided. */
+export type ReportingWebhookUpdate = {
+ endpoint?: string;
+ events?: string[];
+ name?: string;
+ full_resolution?: boolean;
+ with_content?: boolean;
+ disabled?: boolean;
+};
+
+/** A snippet definition for {@link APIClient.createSnippet} / {@link APIClient.updateSnippet}. */
+export type SnippetInput = {
+ /** The snippet's unique name/key. */
+ name: string;
+ /** The snippet's value (may contain Liquid). */
+ value: string;
+};
+
+/** Options for {@link APIClient.getSenderIdentities}. */
+export type SenderIdentitiesOptions = PaginationOptions & {
+ /** Sort direction. Defaults to `asc`. */
+ sort?: SortDirection;
+ /** Filter by hidden status. Omit to return all; `true`/`false` to filter. */
+ hidden?: boolean;
+};
+
+/** Options for {@link APIClient.getMessages}. */
+export type MessagesOptions = PaginationOptions & {
+ /** Return only drafts (`true`) or exclude them (default). */
+ drafts?: boolean;
+ /** Filter to deliveries with this metric (e.g. `delivered`, `opened`, `bounced`). */
+ metric?: string;
+ /** Scope to a single channel. */
+ type?: MetricType;
+ /** Scope to a single campaign. */
+ campaign_id?: string | number;
+ /** Scope to a single campaign action. */
+ action_id?: string | number;
+ /** Scope to a single newsletter. */
+ newsletter_id?: string | number;
+ /** Scope to a single transactional message. */
+ transactional_id?: string | number;
+ /** Scope to a single broadcast trigger (requires `campaign_id`). */
+ trigger_id?: string | number;
+ /** Scope to a single template. */
+ template_id?: string | number;
+ /** Scope to a single content id. */
+ content_id?: string | number;
+ /** Only include deliveries after this Unix timestamp (seconds). */
+ start_ts?: number;
+ /** Only include deliveries before this Unix timestamp (seconds). */
+ end_ts?: number;
+ /** Include the related campaigns/actions/newsletters/contents in the response. */
+ associations?: boolean;
+ /** Include tracked responses on each delivery. */
+ get_tracked_responses?: boolean;
+};
+
+/** Options for {@link APIClient.getMessage}. */
+export type MessageOptions = {
+ /** Include the archived message content (rate-limited). */
+ archived_message?: boolean;
+ /** Include the related campaign/action/newsletter/content in the response. */
+ associations?: boolean;
+ /** Include tracked responses on the delivery. */
+ get_tracked_responses?: boolean;
+};
+
+/** What a CSV import loads. */
+export type ImportType = 'people' | 'event' | 'object' | 'relationship';
+
+/** Which records an import processes. */
+export type ImportProcessScope = 'all' | 'only_existing' | 'only_new';
+
+/** Definition for creating a CSV import via {@link APIClient.createImport}. */
+export type ImportInput = {
+ /** URL of the CSV file to import (required). */
+ data_file_url: string;
+ /** What the CSV loads (required). */
+ type: ImportType;
+ /** Which identifier the CSV keys rows by. `id`/`email` for people & events; `id`/`email`/`cio_id` for relationships. */
+ identifier?: 'id' | 'email' | 'cio_id';
+ /** Object type id — required when `type` is `object`. */
+ object_type_id?: string | number;
+ /** Display name (defaults to the filename). */
+ name?: string;
+ /** Description of the import. */
+ description?: string;
+ /** For people imports: which profiles to process. Mutually exclusive with `data_to_process`. */
+ people_to_process?: ImportProcessScope;
+ /** For object/relationship imports: which records to process. Mutually exclusive with `people_to_process`. */
+ data_to_process?: ImportProcessScope;
+};
+
+/** A single attribute-metadata update for {@link APIClient.batchUpdateAttributes}. */
+export type DataIndexAttribute = {
+ /** The attribute name (required). */
+ name: string;
+ /** A human-readable description. */
+ description?: string;
+ /** Scope the attribute to an object type. */
+ object_type_id?: number;
+ /** Whether the attribute is a relationship attribute. */
+ is_relationship?: boolean;
+ /** Scope the attribute to an event. */
+ event_name?: string;
+ /** Privacy level (requires the sensitive-attributes feature). */
+ privacy_level?: number;
+};
+
+/** A single event-metadata update for {@link APIClient.batchUpdateEvents}. */
+export type DataIndexEvent = {
+ /** The event name (required). */
+ name: string;
+ /** A human-readable description. */
+ description?: string;
+};
+
type APIDefaults = RequestDefaults & { region: Region; url?: string; retry?: Partial };
type Recipients = Record;
@@ -3187,6 +3372,582 @@ export class APIClient {
return this.request.destroy(`${this.apiRoot}/assets/folders/${encodeURIComponent(folderId)}`);
}
+
+ /**
+ * List the collections in your workspace.
+ *
+ * @returns The parsed JSON response body (`{ collections: [...] }`).
+ */
+ listCollections() {
+ return this.request.get(`${this.apiRoot}/collections`);
+ }
+
+ /**
+ * Create a collection. Provide inline `data` or a source `url`, not both.
+ *
+ * @param collection The collection definition. `name` is required. See {@link CollectionInput}.
+ * @returns The parsed JSON response body (`{ collection: {...} }`).
+ * @throws {MissingParamError} If `collection` is missing/not an object, or `collection.name` is empty.
+ */
+ createCollection(collection: CollectionInput) {
+ if (collection == null || typeof collection !== 'object') {
+ throw new MissingParamError('collection');
+ }
+
+ if (isEmpty(collection.name)) {
+ throw new MissingParamError('collection.name');
+ }
+
+ return this.request.post(`${this.apiRoot}/collections`, collection);
+ }
+
+ /**
+ * Get a single collection's metadata (name, schema, row count, size).
+ *
+ * @param collectionId The collection's numeric id.
+ * @returns The parsed JSON response body (`{ collection: {...} }`).
+ * @throws {MissingParamError} If `collectionId` is empty.
+ */
+ getCollection(collectionId: string | number) {
+ if (isEmpty(collectionId)) {
+ throw new MissingParamError('collectionId');
+ }
+
+ return this.request.get(`${this.apiRoot}/collections/${encodeURIComponent(collectionId)}`);
+ }
+
+ /**
+ * Update a collection. Any subset of fields may be provided; `data` and `url` are mutually exclusive.
+ *
+ * @param collectionId The collection's numeric id.
+ * @param updates The fields to change. See {@link CollectionUpdate}.
+ * @returns The parsed JSON response body (`{ collection: {...} }`).
+ * @throws {MissingParamError} If `collectionId` is empty or `updates` is missing/not an object.
+ */
+ updateCollection(collectionId: string | number, updates: CollectionUpdate) {
+ if (isEmpty(collectionId)) {
+ throw new MissingParamError('collectionId');
+ }
+
+ if (updates == null || typeof updates !== 'object') {
+ throw new MissingParamError('updates');
+ }
+
+ return this.request.put(`${this.apiRoot}/collections/${encodeURIComponent(collectionId)}`, updates);
+ }
+
+ /**
+ * Delete a collection. Fails if the collection is still referenced by a campaign.
+ *
+ * @param collectionId The collection's numeric id.
+ * @returns The parsed JSON response body (empty on success — the API returns 204).
+ * @throws {MissingParamError} If `collectionId` is empty.
+ */
+ deleteCollection(collectionId: string | number) {
+ if (isEmpty(collectionId)) {
+ throw new MissingParamError('collectionId');
+ }
+
+ return this.request.destroy(`${this.apiRoot}/collections/${encodeURIComponent(collectionId)}`);
+ }
+
+ /**
+ * Get a collection's content — the full array of data rows.
+ *
+ * @param collectionId The collection's numeric id.
+ * @returns The parsed JSON response body: an array of row objects.
+ * @throws {MissingParamError} If `collectionId` is empty.
+ */
+ getCollectionContent(collectionId: string | number) {
+ if (isEmpty(collectionId)) {
+ throw new MissingParamError('collectionId');
+ }
+
+ return this.request.get(`${this.apiRoot}/collections/${encodeURIComponent(collectionId)}/content`);
+ }
+
+ /**
+ * Replace a collection's content with a new array of data rows.
+ *
+ * @param collectionId The collection's numeric id.
+ * @param content The full array of row objects to store.
+ * @returns The parsed JSON response body (`{ collection: {...} }`).
+ * @throws {MissingParamError} If `collectionId` is empty or `content` is not an array.
+ */
+ updateCollectionContent(collectionId: string | number, content: CollectionRow[]) {
+ if (isEmpty(collectionId)) {
+ throw new MissingParamError('collectionId');
+ }
+
+ if (!Array.isArray(content)) {
+ throw new MissingParamError('content');
+ }
+
+ // This endpoint takes a top-level JSON array; the transport serializes it
+ // as-is. The cast keeps `RequestData` object-shaped for its other callers.
+ return this.request.put(
+ `${this.apiRoot}/collections/${encodeURIComponent(collectionId)}/content`,
+ content as unknown as Record,
+ );
+ }
+
+ /**
+ * Search every suppression category for an email address.
+ *
+ * @param email The email address to search for.
+ * @returns The parsed JSON response body (`{ category, suppressions: [...] }`).
+ * @throws {MissingParamError} If `email` is empty.
+ */
+ searchSuppression(email: string) {
+ if (isEmpty(email)) {
+ throw new MissingParamError('email');
+ }
+
+ return this.request.get(`${this.apiRoot}/esp/search_suppression/${encodeURIComponent(email)}`);
+ }
+
+ /**
+ * List suppressions in a category (offset-based pagination).
+ *
+ * @param suppressionType One of `blocks`, `bounces`, `spam_reports`, `invalid_emails`.
+ * @param options Optional filters and pagination. See {@link SuppressionsOptions}.
+ * @returns The parsed JSON response body (`{ category, suppressions: [...] }`).
+ * @throws {MissingParamError} If `suppressionType` is empty.
+ */
+ getSuppressions(suppressionType: SuppressionType, options: SuppressionsOptions = {}) {
+ if (isEmpty(suppressionType)) {
+ throw new MissingParamError('suppressionType');
+ }
+
+ const query = buildQueryString({
+ limit: options.limit,
+ offset: options.offset,
+ email: options.email,
+ domain: options.domain,
+ });
+
+ return this.request.get(`${this.apiRoot}/esp/suppression/${encodeURIComponent(suppressionType)}${query}`);
+ }
+
+ /**
+ * List suppressions in a category for a single sending domain (cursor-based pagination).
+ *
+ * @param domainName The sending domain.
+ * @param suppressionType One of `blocks`, `bounces`, `spam_reports`, `invalid_emails`.
+ * @param options Optional filter and pagination. See {@link DomainSuppressionsOptions}.
+ * @returns The parsed JSON response body (`{ category, suppressions: [...], next }`).
+ * @throws {MissingParamError} If `domainName` or `suppressionType` is empty.
+ */
+ getDomainSuppressions(domainName: string, suppressionType: SuppressionType, options: DomainSuppressionsOptions = {}) {
+ if (isEmpty(domainName)) {
+ throw new MissingParamError('domainName');
+ }
+
+ if (isEmpty(suppressionType)) {
+ throw new MissingParamError('suppressionType');
+ }
+
+ const query = buildQueryString({
+ limit: options.limit,
+ email: options.email,
+ start: options.start,
+ });
+
+ return this.request.get(
+ `${this.apiRoot}/esp/domains/${encodeURIComponent(domainName)}/suppression/${encodeURIComponent(suppressionType)}${query}`,
+ );
+ }
+
+ /**
+ * Add an email address to a suppression category.
+ *
+ * @param suppressionType One of `blocks`, `bounces`, `spam_reports`, `invalid_emails`.
+ * @param email The email address to suppress.
+ * @returns The parsed JSON response body.
+ * @throws {MissingParamError} If `suppressionType` or `email` is empty.
+ */
+ createSuppression(suppressionType: SuppressionType, email: string) {
+ if (isEmpty(suppressionType)) {
+ throw new MissingParamError('suppressionType');
+ }
+
+ if (isEmpty(email)) {
+ throw new MissingParamError('email');
+ }
+
+ return this.request.post(
+ `${this.apiRoot}/esp/suppression/${encodeURIComponent(suppressionType)}/${encodeURIComponent(email)}`,
+ );
+ }
+
+ /**
+ * Remove an email address from a suppression category.
+ *
+ * @param suppressionType One of `blocks`, `bounces`, `spam_reports`, `invalid_emails`.
+ * @param email The email address to unsuppress.
+ * @returns The parsed JSON response body (empty on success — the API returns 204).
+ * @throws {MissingParamError} If `suppressionType` or `email` is empty.
+ */
+ deleteSuppression(suppressionType: SuppressionType, email: string) {
+ if (isEmpty(suppressionType)) {
+ throw new MissingParamError('suppressionType');
+ }
+
+ if (isEmpty(email)) {
+ throw new MissingParamError('email');
+ }
+
+ return this.request.destroy(
+ `${this.apiRoot}/esp/suppression/${encodeURIComponent(suppressionType)}/${encodeURIComponent(email)}`,
+ );
+ }
+
+ /**
+ * List the reporting webhooks in your workspace.
+ *
+ * @returns The parsed JSON response body (`{ reporting_webhooks: [...] }`).
+ */
+ listReportingWebhooks() {
+ return this.request.get(`${this.apiRoot}/reporting_webhooks`);
+ }
+
+ /**
+ * Create a reporting webhook.
+ *
+ * @param webhook The webhook definition. `endpoint` is required. See {@link ReportingWebhookInput}.
+ * @returns The parsed JSON response body (the created webhook).
+ * @throws {MissingParamError} If `webhook` is missing/not an object, or `webhook.endpoint` is empty.
+ */
+ createReportingWebhook(webhook: ReportingWebhookInput) {
+ if (webhook == null || typeof webhook !== 'object') {
+ throw new MissingParamError('webhook');
+ }
+
+ if (isEmpty(webhook.endpoint)) {
+ throw new MissingParamError('webhook.endpoint');
+ }
+
+ return this.request.post(`${this.apiRoot}/reporting_webhooks`, webhook);
+ }
+
+ /**
+ * Get a single reporting webhook.
+ *
+ * @param webhookId The webhook's numeric id.
+ * @returns The parsed JSON response body (the webhook).
+ * @throws {MissingParamError} If `webhookId` is empty.
+ */
+ getReportingWebhook(webhookId: string | number) {
+ if (isEmpty(webhookId)) {
+ throw new MissingParamError('webhookId');
+ }
+
+ return this.request.get(`${this.apiRoot}/reporting_webhooks/${encodeURIComponent(webhookId)}`);
+ }
+
+ /**
+ * Update a reporting webhook. Any subset of fields may be provided.
+ *
+ * @param webhookId The webhook's numeric id.
+ * @param updates The fields to change. See {@link ReportingWebhookUpdate}.
+ * @returns The parsed JSON response body (the updated webhook).
+ * @throws {MissingParamError} If `webhookId` is empty or `updates` is missing/not an object.
+ */
+ updateReportingWebhook(webhookId: string | number, updates: ReportingWebhookUpdate) {
+ if (isEmpty(webhookId)) {
+ throw new MissingParamError('webhookId');
+ }
+
+ if (updates == null || typeof updates !== 'object') {
+ throw new MissingParamError('updates');
+ }
+
+ return this.request.put(`${this.apiRoot}/reporting_webhooks/${encodeURIComponent(webhookId)}`, updates);
+ }
+
+ /**
+ * Delete a reporting webhook.
+ *
+ * @param webhookId The webhook's numeric id.
+ * @returns The parsed JSON response body (empty on success — the API returns 204).
+ * @throws {MissingParamError} If `webhookId` is empty.
+ */
+ deleteReportingWebhook(webhookId: string | number) {
+ if (isEmpty(webhookId)) {
+ throw new MissingParamError('webhookId');
+ }
+
+ return this.request.destroy(`${this.apiRoot}/reporting_webhooks/${encodeURIComponent(webhookId)}`);
+ }
+
+ /**
+ * List the snippets in your workspace.
+ *
+ * @returns The parsed JSON response body (`{ snippets: [...] }`).
+ */
+ getSnippets() {
+ return this.request.get(`${this.apiRoot}/snippets`);
+ }
+
+ /**
+ * Create a snippet.
+ *
+ * @param snippet The snippet definition. `name` and `value` are required. See {@link SnippetInput}.
+ * @returns The parsed JSON response body (`{ snippet: {...} }`).
+ * @throws {MissingParamError} If `snippet` is missing/not an object, or `snippet.name`/`snippet.value` is empty.
+ */
+ createSnippet(snippet: SnippetInput) {
+ if (snippet == null || typeof snippet !== 'object') {
+ throw new MissingParamError('snippet');
+ }
+
+ if (isEmpty(snippet.name)) {
+ throw new MissingParamError('snippet.name');
+ }
+
+ if (isEmpty(snippet.value)) {
+ throw new MissingParamError('snippet.value');
+ }
+
+ return this.request.post(`${this.apiRoot}/snippets`, snippet);
+ }
+
+ /**
+ * Create or update a snippet (upsert by name).
+ *
+ * @param snippet The snippet definition. `name` and `value` are required. See {@link SnippetInput}.
+ * @returns The parsed JSON response body (`{ snippet: {...} }`).
+ * @throws {MissingParamError} If `snippet` is missing/not an object, or `snippet.name`/`snippet.value` is empty.
+ */
+ updateSnippet(snippet: SnippetInput) {
+ if (snippet == null || typeof snippet !== 'object') {
+ throw new MissingParamError('snippet');
+ }
+
+ if (isEmpty(snippet.name)) {
+ throw new MissingParamError('snippet.name');
+ }
+
+ if (isEmpty(snippet.value)) {
+ throw new MissingParamError('snippet.value');
+ }
+
+ return this.request.put(`${this.apiRoot}/snippets`, snippet);
+ }
+
+ /**
+ * Delete a snippet by name. Fails if the snippet is still in use.
+ *
+ * @param name The snippet's name.
+ * @returns The parsed JSON response body (empty on success — the API returns 204).
+ * @throws {MissingParamError} If `name` is empty.
+ */
+ deleteSnippet(name: string) {
+ if (isEmpty(name)) {
+ throw new MissingParamError('name');
+ }
+
+ return this.request.destroy(`${this.apiRoot}/snippets/${encodeURIComponent(name)}`);
+ }
+
+ /**
+ * List the sender identities in your workspace.
+ *
+ * @param options Optional sort, pagination, and hidden filter. See {@link SenderIdentitiesOptions}.
+ * @returns The parsed JSON response body (`{ sender_identities: [...], next }`).
+ */
+ getSenderIdentities(options: SenderIdentitiesOptions = {}) {
+ const query = buildQueryString({
+ start: options.start,
+ limit: options.limit,
+ sort: options.sort,
+ hidden: options.hidden,
+ });
+
+ return this.request.get(`${this.apiRoot}/sender_identities${query}`);
+ }
+
+ /**
+ * Get a single sender identity.
+ *
+ * @param senderId The sender identity's numeric id.
+ * @returns The parsed JSON response body (`{ sender_identity: {...} }`).
+ * @throws {MissingParamError} If `senderId` is empty.
+ */
+ getSenderIdentity(senderId: string | number) {
+ if (isEmpty(senderId)) {
+ throw new MissingParamError('senderId');
+ }
+
+ return this.request.get(`${this.apiRoot}/sender_identities/${encodeURIComponent(senderId)}`);
+ }
+
+ /**
+ * List the campaigns and newsletters that use a sender identity.
+ *
+ * @param senderId The sender identity's numeric id.
+ * @returns The parsed JSON response body (`{ campaigns, sent_newsletters, draft_newsletters }`).
+ * @throws {MissingParamError} If `senderId` is empty.
+ */
+ getSenderIdentityUsedBy(senderId: string | number) {
+ if (isEmpty(senderId)) {
+ throw new MissingParamError('senderId');
+ }
+
+ return this.request.get(`${this.apiRoot}/sender_identities/${encodeURIComponent(senderId)}/used_by`);
+ }
+
+ /**
+ * List sent messages (deliveries) across your workspace.
+ *
+ * @param options Optional filters and pagination. See {@link MessagesOptions}.
+ * @returns The parsed JSON response body (`{ messages: [...], next, ... }`).
+ */
+ getMessages(options: MessagesOptions = {}) {
+ const query = buildQueryString({
+ start: options.start,
+ limit: options.limit,
+ drafts: options.drafts,
+ metric: options.metric,
+ type: options.type,
+ campaign_id: options.campaign_id,
+ action_id: options.action_id,
+ newsletter_id: options.newsletter_id,
+ transactional_id: options.transactional_id,
+ trigger_id: options.trigger_id,
+ template_id: options.template_id,
+ content_id: options.content_id,
+ start_ts: options.start_ts,
+ end_ts: options.end_ts,
+ associations: options.associations,
+ get_tracked_responses: options.get_tracked_responses,
+ });
+
+ return this.request.get(`${this.apiRoot}/messages${query}`);
+ }
+
+ /**
+ * Get a single sent message (delivery).
+ *
+ * @param messageId The delivery id (`CIO-Delivery-ID`).
+ * @param options Optional response expansions. See {@link MessageOptions}.
+ * @returns The parsed JSON response body (`{ message: {...}, ... }`).
+ * @throws {MissingParamError} If `messageId` is empty.
+ */
+ getMessage(messageId: string, options: MessageOptions = {}) {
+ if (isEmpty(messageId)) {
+ throw new MissingParamError('messageId');
+ }
+
+ const query = buildQueryString({
+ archived_message: options.archived_message,
+ associations: options.associations,
+ get_tracked_responses: options.get_tracked_responses,
+ });
+
+ return this.request.get(`${this.apiRoot}/messages/${encodeURIComponent(messageId)}${query}`);
+ }
+
+ /**
+ * Get the archived content of a single sent message.
+ *
+ * @param messageId The delivery id (`CIO-Delivery-ID`).
+ * @returns The parsed JSON response body (`{ archived_message: {...} }`).
+ * @throws {MissingParamError} If `messageId` is empty.
+ */
+ getArchivedMessage(messageId: string) {
+ if (isEmpty(messageId)) {
+ throw new MissingParamError('messageId');
+ }
+
+ return this.request.get(`${this.apiRoot}/messages/${encodeURIComponent(messageId)}/archived_message`);
+ }
+
+ /**
+ * Start a CSV import.
+ *
+ * @param importData The import definition. `data_file_url` and `type` are required. See {@link ImportInput}.
+ * @returns The parsed JSON response body (`{ import: {...} }`).
+ * @throws {MissingParamError} If `importData` is missing/not an object, or `data_file_url`/`type` is empty.
+ */
+ createImport(importData: ImportInput) {
+ if (importData == null || typeof importData !== 'object') {
+ throw new MissingParamError('importData');
+ }
+
+ if (isEmpty(importData.data_file_url)) {
+ throw new MissingParamError('importData.data_file_url');
+ }
+
+ if (isEmpty(importData.type)) {
+ throw new MissingParamError('importData.type');
+ }
+
+ return this.request.post(`${this.apiRoot}/imports`, { import: importData });
+ }
+
+ /**
+ * Get the status of an import.
+ *
+ * @param importId The import's numeric id.
+ * @returns The parsed JSON response body (`{ import: {...} }`).
+ * @throws {MissingParamError} If `importId` is empty.
+ */
+ getImport(importId: string | number) {
+ if (isEmpty(importId)) {
+ throw new MissingParamError('importId');
+ }
+
+ return this.request.get(`${this.apiRoot}/imports/${encodeURIComponent(importId)}`);
+ }
+
+ /**
+ * Batch-update attribute metadata (up to 100 at a time).
+ *
+ * @param attributes The attribute updates. Each requires a `name`. See {@link DataIndexAttribute}.
+ * @returns The parsed JSON response body (empty on success — the API returns 204).
+ * @throws {MissingParamError} If `attributes` is not a non-empty array.
+ */
+ batchUpdateAttributes(attributes: DataIndexAttribute[]) {
+ if (!Array.isArray(attributes) || attributes.length === 0) {
+ throw new MissingParamError('attributes');
+ }
+
+ return this.request.post(`${this.apiRoot}/data_index/attributes`, { attributes });
+ }
+
+ /**
+ * Batch-update event metadata (up to 100 at a time).
+ *
+ * @param events The event updates. Each requires a `name`. See {@link DataIndexEvent}.
+ * @returns The parsed JSON response body (empty on success — the API returns 204).
+ * @throws {MissingParamError} If `events` is not a non-empty array.
+ */
+ batchUpdateEvents(events: DataIndexEvent[]) {
+ if (!Array.isArray(events) || events.length === 0) {
+ throw new MissingParamError('events');
+ }
+
+ return this.request.post(`${this.apiRoot}/data_index/events`, { events });
+ }
+
+ /**
+ * List the workspaces (environments) in your account, with usage counts.
+ *
+ * @returns The parsed JSON response body (`{ workspaces: [...] }`).
+ */
+ listWorkspaces() {
+ return this.request.get(`${this.apiRoot}/workspaces`);
+ }
+
+ /**
+ * List the Customer.io egress IP addresses (for allowlisting).
+ *
+ * @returns The parsed JSON response body (`{ ip_addresses: [...] }`).
+ */
+ getIpAddresses() {
+ return this.request.get(`${this.apiRoot}/info/ip_addresses`);
+ }
}
export {
diff --git a/test/api.ts b/test/api.ts
index b16d22e..1a740a9 100644
--- a/test/api.ts
+++ b/test/api.ts
@@ -2111,3 +2111,319 @@ test('#deleteAssetFolder: deletes a folder and validates', (t) => {
t.context.client.deleteAssetFolder(2);
t.true(destroy.calledWith(`${API}/assets/folders/2`));
});
+
+// Collections
+
+test('#listCollections: gets the collections endpoint', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.listCollections();
+ t.true(get.calledWith(`${API}/collections`));
+});
+
+test('#createCollection: posts the collection body and validates', (t) => {
+ const post = sinon.stub(t.context.client.request, 'post');
+ t.throws(() => (t.context.client.createCollection as any)(null), { message: 'collection is required' });
+ t.throws(() => (t.context.client.createCollection as any)('nope'), { message: 'collection is required' });
+ t.throws(() => (t.context.client.createCollection as any)({}), { message: 'collection.name is required' });
+ const collection = { name: 'Plans', data: [{ tier: 'pro', price: 20 }] };
+ t.context.client.createCollection(collection);
+ t.true(post.calledWith(`${API}/collections`, collection));
+});
+
+test('#getCollection: gets a single collection and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getCollection(''), { message: 'collectionId is required' });
+ t.context.client.getCollection(9);
+ t.true(get.calledWith(`${API}/collections/9`));
+});
+
+test('#updateCollection: puts the updates and validates', (t) => {
+ const put = sinon.stub(t.context.client.request, 'put');
+ t.throws(() => t.context.client.updateCollection('', { name: 'x' }), { message: 'collectionId is required' });
+ t.throws(() => (t.context.client.updateCollection as any)(9, null), { message: 'updates is required' });
+ t.context.client.updateCollection(9, { name: 'Renamed', url: 'https://example.com/data.csv' });
+ t.true(put.calledWith(`${API}/collections/9`, { name: 'Renamed', url: 'https://example.com/data.csv' }));
+});
+
+test('#deleteCollection: deletes a collection and validates', (t) => {
+ const destroy = sinon.stub(t.context.client.request, 'destroy');
+ t.throws(() => t.context.client.deleteCollection(''), { message: 'collectionId is required' });
+ t.context.client.deleteCollection(9);
+ t.true(destroy.calledWith(`${API}/collections/9`));
+});
+
+test('#getCollectionContent: gets the content endpoint and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getCollectionContent(''), { message: 'collectionId is required' });
+ t.context.client.getCollectionContent(9);
+ t.true(get.calledWith(`${API}/collections/9/content`));
+});
+
+test('#updateCollectionContent: puts the raw row array and validates', (t) => {
+ const put = sinon.stub(t.context.client.request, 'put');
+ t.throws(() => t.context.client.updateCollectionContent('', [{ a: 1 }]), { message: 'collectionId is required' });
+ t.throws(() => (t.context.client.updateCollectionContent as any)(9, { a: 1 }), { message: 'content is required' });
+ const rows = [{ tier: 'pro' }, { tier: 'free' }];
+ t.context.client.updateCollectionContent(9, rows);
+ t.true(put.calledWith(`${API}/collections/9/content`, rows));
+});
+
+// Deliverability: ESP suppressions
+
+test('#searchSuppression: gets the search endpoint and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.searchSuppression(''), { message: 'email is required' });
+ t.context.client.searchSuppression('user+tag@example.com');
+ t.true(get.calledWith(`${API}/esp/search_suppression/user%2Btag%40example.com`));
+});
+
+test('#getSuppressions: gets the category endpoint with filters and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => (t.context.client.getSuppressions as any)(''), { message: 'suppressionType is required' });
+ t.context.client.getSuppressions('bounces');
+ t.true(get.calledWith(`${API}/esp/suppression/bounces`));
+ t.context.client.getSuppressions('spam_reports', { limit: 50, offset: 100, email: 'a@b.com', domain: 'b.com' });
+ t.true(get.calledWith(`${API}/esp/suppression/spam_reports?limit=50&offset=100&email=a%40b.com&domain=b.com`));
+});
+
+test('#getDomainSuppressions: gets the domain endpoint with cursor and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => (t.context.client.getDomainSuppressions as any)('', 'bounces'), { message: 'domainName is required' });
+ t.throws(() => (t.context.client.getDomainSuppressions as any)('b.com', ''), {
+ message: 'suppressionType is required',
+ });
+ t.context.client.getDomainSuppressions('mail.example.com', 'bounces', { limit: 10, start: 'abc' });
+ t.true(get.calledWith(`${API}/esp/domains/mail.example.com/suppression/bounces?limit=10&start=abc`));
+});
+
+test('#createSuppression: posts to the suppression endpoint and validates', (t) => {
+ const post = sinon.stub(t.context.client.request, 'post');
+ t.throws(() => (t.context.client.createSuppression as any)('', 'a@b.com'), {
+ message: 'suppressionType is required',
+ });
+ t.throws(() => t.context.client.createSuppression('bounces', ''), { message: 'email is required' });
+ t.context.client.createSuppression('bounces', 'a@b.com');
+ t.true(post.calledWith(`${API}/esp/suppression/bounces/a%40b.com`));
+});
+
+test('#deleteSuppression: deletes the suppression and validates', (t) => {
+ const destroy = sinon.stub(t.context.client.request, 'destroy');
+ t.throws(() => (t.context.client.deleteSuppression as any)('', 'a@b.com'), {
+ message: 'suppressionType is required',
+ });
+ t.throws(() => t.context.client.deleteSuppression('bounces', ''), { message: 'email is required' });
+ t.context.client.deleteSuppression('bounces', 'a@b.com');
+ t.true(destroy.calledWith(`${API}/esp/suppression/bounces/a%40b.com`));
+});
+
+// Deliverability: reporting webhooks
+
+test('#listReportingWebhooks: gets the reporting webhooks endpoint', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.listReportingWebhooks();
+ t.true(get.calledWith(`${API}/reporting_webhooks`));
+});
+
+test('#createReportingWebhook: posts the webhook body and validates', (t) => {
+ const post = sinon.stub(t.context.client.request, 'post');
+ t.throws(() => (t.context.client.createReportingWebhook as any)(null), { message: 'webhook is required' });
+ t.throws(() => (t.context.client.createReportingWebhook as any)({ events: ['sent'] }), {
+ message: 'webhook.endpoint is required',
+ });
+ const webhook = { endpoint: 'https://example.com/hook', events: ['sent', 'delivered'], name: 'Prod' };
+ t.context.client.createReportingWebhook(webhook);
+ t.true(post.calledWith(`${API}/reporting_webhooks`, webhook));
+});
+
+test('#getReportingWebhook: gets a single webhook and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getReportingWebhook(''), { message: 'webhookId is required' });
+ t.context.client.getReportingWebhook(4);
+ t.true(get.calledWith(`${API}/reporting_webhooks/4`));
+});
+
+test('#updateReportingWebhook: puts the updates and validates', (t) => {
+ const put = sinon.stub(t.context.client.request, 'put');
+ t.throws(() => t.context.client.updateReportingWebhook('', { disabled: true }), { message: 'webhookId is required' });
+ t.throws(() => (t.context.client.updateReportingWebhook as any)(4, null), { message: 'updates is required' });
+ t.context.client.updateReportingWebhook(4, { disabled: true, events: ['sent'] });
+ t.true(put.calledWith(`${API}/reporting_webhooks/4`, { disabled: true, events: ['sent'] }));
+});
+
+test('#deleteReportingWebhook: deletes a webhook and validates', (t) => {
+ const destroy = sinon.stub(t.context.client.request, 'destroy');
+ t.throws(() => t.context.client.deleteReportingWebhook(''), { message: 'webhookId is required' });
+ t.context.client.deleteReportingWebhook(4);
+ t.true(destroy.calledWith(`${API}/reporting_webhooks/4`));
+});
+
+// Snippets
+
+test('#getSnippets: gets the snippets endpoint', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.getSnippets();
+ t.true(get.calledWith(`${API}/snippets`));
+});
+
+test('#createSnippet: posts the snippet body and validates', (t) => {
+ const post = sinon.stub(t.context.client.request, 'post');
+ t.throws(() => (t.context.client.createSnippet as any)(null), { message: 'snippet is required' });
+ t.throws(() => (t.context.client.createSnippet as any)({ value: 'v' }), { message: 'snippet.name is required' });
+ t.throws(() => (t.context.client.createSnippet as any)({ name: 'n' }), { message: 'snippet.value is required' });
+ const snippet = { name: 'footer', value: '© 2026
' };
+ t.context.client.createSnippet(snippet);
+ t.true(post.calledWith(`${API}/snippets`, snippet));
+});
+
+test('#updateSnippet: puts the snippet body (upsert) and validates', (t) => {
+ const put = sinon.stub(t.context.client.request, 'put');
+ t.throws(() => (t.context.client.updateSnippet as any)(null), { message: 'snippet is required' });
+ t.throws(() => (t.context.client.updateSnippet as any)({ value: 'v' }), { message: 'snippet.name is required' });
+ t.throws(() => (t.context.client.updateSnippet as any)({ name: 'n' }), { message: 'snippet.value is required' });
+ const snippet = { name: 'footer', value: 'updated
' };
+ t.context.client.updateSnippet(snippet);
+ t.true(put.calledWith(`${API}/snippets`, snippet));
+});
+
+test('#deleteSnippet: deletes a snippet by name and validates', (t) => {
+ const destroy = sinon.stub(t.context.client.request, 'destroy');
+ t.throws(() => t.context.client.deleteSnippet(''), { message: 'name is required' });
+ t.context.client.deleteSnippet('foo bar');
+ t.true(destroy.calledWith(`${API}/snippets/foo%20bar`));
+});
+
+// Sender identities
+
+test('#getSenderIdentities: gets the endpoint with sort/pagination/hidden', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.getSenderIdentities();
+ t.true(get.calledWith(`${API}/sender_identities`));
+ t.context.client.getSenderIdentities({ start: 'cur', limit: 10, sort: 'desc', hidden: false });
+ t.true(get.calledWith(`${API}/sender_identities?start=cur&limit=10&sort=desc&hidden=false`));
+});
+
+test('#getSenderIdentity: gets a single identity and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getSenderIdentity(''), { message: 'senderId is required' });
+ t.context.client.getSenderIdentity(12);
+ t.true(get.calledWith(`${API}/sender_identities/12`));
+});
+
+test('#getSenderIdentityUsedBy: gets the used_by endpoint and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getSenderIdentityUsedBy(''), { message: 'senderId is required' });
+ t.context.client.getSenderIdentityUsedBy(12);
+ t.true(get.calledWith(`${API}/sender_identities/12/used_by`));
+});
+
+// Messages
+
+test('#getMessages: gets the messages endpoint with no query by default', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.getMessages();
+ t.true(get.calledWith(`${API}/messages`));
+});
+
+test('#getMessages: forwards filters and pagination', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.getMessages({
+ start: 'cur',
+ limit: 20,
+ drafts: false,
+ metric: 'delivered',
+ type: 'email',
+ campaign_id: 3,
+ action_id: 4,
+ newsletter_id: 5,
+ transactional_id: 6,
+ trigger_id: 7,
+ template_id: 8,
+ content_id: 9,
+ start_ts: 100,
+ end_ts: 200,
+ associations: true,
+ get_tracked_responses: true,
+ });
+ t.true(
+ get.calledWith(
+ `${API}/messages?start=cur&limit=20&drafts=false&metric=delivered&type=email&campaign_id=3&action_id=4&newsletter_id=5&transactional_id=6&trigger_id=7&template_id=8&content_id=9&start_ts=100&end_ts=200&associations=true&get_tracked_responses=true`,
+ ),
+ );
+});
+
+test('#getMessage: gets a single message with expansions and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getMessage(''), { message: 'messageId is required' });
+ t.context.client.getMessage('abc-123');
+ t.true(get.calledWith(`${API}/messages/abc-123`));
+ t.context.client.getMessage('abc-123', { archived_message: true, associations: true });
+ t.true(get.calledWith(`${API}/messages/abc-123?archived_message=true&associations=true`));
+});
+
+test('#getArchivedMessage: gets the archived_message endpoint and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getArchivedMessage(''), { message: 'messageId is required' });
+ t.context.client.getArchivedMessage('abc-123');
+ t.true(get.calledWith(`${API}/messages/abc-123/archived_message`));
+});
+
+// Imports
+
+test('#createImport: posts the wrapped import body and validates', (t) => {
+ const post = sinon.stub(t.context.client.request, 'post');
+ t.throws(() => (t.context.client.createImport as any)(null), { message: 'importData is required' });
+ t.throws(() => (t.context.client.createImport as any)({ type: 'people' }), {
+ message: 'importData.data_file_url is required',
+ });
+ t.throws(() => (t.context.client.createImport as any)({ data_file_url: 'https://x/f.csv' }), {
+ message: 'importData.type is required',
+ });
+ const imp = {
+ data_file_url: 'https://example.com/people.csv',
+ type: 'people' as const,
+ identifier: 'email' as const,
+ };
+ t.context.client.createImport(imp);
+ t.true(post.calledWith(`${API}/imports`, { import: imp }));
+});
+
+test('#getImport: gets a single import and validates', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.throws(() => t.context.client.getImport(''), { message: 'importId is required' });
+ t.context.client.getImport(15);
+ t.true(get.calledWith(`${API}/imports/15`));
+});
+
+// Data index
+
+test('#batchUpdateAttributes: posts the wrapped attributes and validates', (t) => {
+ const post = sinon.stub(t.context.client.request, 'post');
+ t.throws(() => (t.context.client.batchUpdateAttributes as any)(null), { message: 'attributes is required' });
+ t.throws(() => t.context.client.batchUpdateAttributes([]), { message: 'attributes is required' });
+ const attributes = [{ name: 'plan', description: 'Subscription plan' }];
+ t.context.client.batchUpdateAttributes(attributes);
+ t.true(post.calledWith(`${API}/data_index/attributes`, { attributes }));
+});
+
+test('#batchUpdateEvents: posts the wrapped events and validates', (t) => {
+ const post = sinon.stub(t.context.client.request, 'post');
+ t.throws(() => (t.context.client.batchUpdateEvents as any)(null), { message: 'events is required' });
+ t.throws(() => t.context.client.batchUpdateEvents([]), { message: 'events is required' });
+ const events = [{ name: 'purchase', description: 'Completed a purchase' }];
+ t.context.client.batchUpdateEvents(events);
+ t.true(post.calledWith(`${API}/data_index/events`, { events }));
+});
+
+// Misc
+
+test('#listWorkspaces: gets the workspaces endpoint', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.listWorkspaces();
+ t.true(get.calledWith(`${API}/workspaces`));
+});
+
+test('#getIpAddresses: gets the ip_addresses endpoint', (t) => {
+ const get = sinon.stub(t.context.client.request, 'get');
+ t.context.client.getIpAddresses();
+ t.true(get.calledWith(`${API}/info/ip_addresses`));
+});
diff --git a/test/integration/live.env.example b/test/integration/live.env.example
index dfee026..17031e2 100644
--- a/test/integration/live.env.example
+++ b/test/integration/live.env.example
@@ -52,3 +52,5 @@ CIO_TEST_DELIVERY_ID=
# Object type id (+ optional object id) for object attribute/relationship/find reads
CIO_TEST_OBJECT_TYPE_ID=
CIO_TEST_OBJECT_ID=
+# Downloadable CSV URL for createImport
+CIO_TEST_IMPORT_CSV_URL=
diff --git a/test/integration/live.ts b/test/integration/live.ts
index 24f3866..e201f61 100644
--- a/test/integration/live.ts
+++ b/test/integration/live.ts
@@ -28,6 +28,7 @@
* CIO_TEST_OBJECT_ID object id for object reads (defaults to a throwaway id)
* CIO_TEST_CAMPAIGN_ID campaign id for campaign read methods
* CIO_TEST_TRIGGER_ID trigger id (with CIO_TEST_BROADCAST_ID) for trigger status/errors
+ * CIO_TEST_IMPORT_CSV_URL downloadable CSV URL for createImport
*
* Profile lifecycle: one throwaway customer per run, id = `sdk-live-${uuid()}`.
* Cleanup is best-effort; the dedicated workspace tolerates orphans.
@@ -425,6 +426,122 @@ liveTest('asset folder + file create -> read -> update -> delete round-trip', as
t.pass();
});
+liveTest('listCollections resolves', async (t) => {
+ const result = (await api!.listCollections()) as { collections?: unknown };
+ t.true('collections' in result);
+});
+
+liveTest('collection create -> read -> content -> delete round-trip', async (t) => {
+ const created = (await api!
+ .createCollection({ name: `sdk-live-${customerId}`, data: [{ tier: 'pro', price: 20 }] })
+ .catch(() => undefined)) as { collection?: { id?: number } } | undefined;
+ const collectionId = created?.collection?.id;
+
+ if (collectionId !== undefined) {
+ await api!.getCollection(collectionId).catch(() => undefined);
+ await api!.getCollectionContent(collectionId).catch(() => undefined);
+ await api!.updateCollection(collectionId, { name: `sdk-live-${customerId}-renamed` }).catch(() => undefined);
+ await api!.updateCollectionContent(collectionId, [{ tier: 'free', price: 0 }]).catch(() => undefined);
+ await api!.deleteCollection(collectionId).catch(() => undefined);
+ }
+
+ t.pass();
+});
+
+liveTest('esp suppression reads resolve', async (t) => {
+ const list = (await api!.getSuppressions('bounces', { limit: 1 })) as { suppressions?: unknown };
+ t.true('suppressions' in list);
+ await api!.searchSuppression(`absent-${randomUUID()}@example.com`).catch(() => undefined);
+ t.pass();
+});
+
+liveTest('esp suppression create -> delete round-trip', async (t) => {
+ const email = `sdk-live-${randomUUID()}@example.com`;
+ await api!.createSuppression('bounces', email).catch(() => undefined);
+ await api!.deleteSuppression('bounces', email).catch(() => undefined);
+ t.pass();
+});
+
+liveTest('listReportingWebhooks resolves', async (t) => {
+ const result = (await api!.listReportingWebhooks()) as { reporting_webhooks?: unknown };
+ t.true('reporting_webhooks' in result);
+});
+
+liveTest('reporting webhook create -> read -> update -> delete round-trip', async (t) => {
+ const created = (await api!
+ .createReportingWebhook({
+ endpoint: `https://example.com/sdk-live-${customerId}`,
+ events: ['sent', 'delivered'],
+ name: `sdk-live-${customerId}`,
+ disabled: true,
+ })
+ .catch(() => undefined)) as { id?: number } | undefined;
+ const webhookId = created?.id;
+
+ if (webhookId !== undefined) {
+ await api!.getReportingWebhook(webhookId).catch(() => undefined);
+ await api!.updateReportingWebhook(webhookId, { name: `sdk-live-${customerId}-renamed` }).catch(() => undefined);
+ await api!.deleteReportingWebhook(webhookId).catch(() => undefined);
+ }
+
+ t.pass();
+});
+
+liveTest('snippet upsert -> read -> delete round-trip', async (t) => {
+ const name = `sdk-live-${customerId}`;
+ await api!.updateSnippet({ name, value: 'hello' }).catch(() => undefined);
+ const list = (await api!.getSnippets()) as { snippets?: unknown };
+ t.true('snippets' in list);
+ await api!.deleteSnippet(name).catch(() => undefined);
+ t.pass();
+});
+
+liveTest('sender identity reads resolve', async (t) => {
+ const result = (await api!.getSenderIdentities({ limit: 5 })) as { sender_identities?: unknown };
+ t.true('sender_identities' in result);
+});
+
+liveTest('getMessages resolves', async (t) => {
+ const result = (await api!.getMessages({ limit: 5 })) as { messages?: unknown };
+ t.true('messages' in result);
+});
+
+liveTest('listWorkspaces resolves', async (t) => {
+ const result = (await api!.listWorkspaces()) as { workspaces?: unknown };
+ t.true('workspaces' in result);
+});
+
+liveTest('getIpAddresses resolves', async (t) => {
+ const result = (await api!.getIpAddresses()) as { ip_addresses?: unknown };
+ t.true('ip_addresses' in result);
+});
+
+liveTest('data_index batch updates resolve', async (t) => {
+ await api!
+ .batchUpdateAttributes([{ name: `sdk_live_${customerId}`, description: 'sdk live test' }])
+ .catch(() => undefined);
+ await api!
+ .batchUpdateEvents([{ name: `sdk_live_${customerId}`, description: 'sdk live test' }])
+ .catch(() => undefined);
+ t.pass();
+});
+
+needs('CIO_TEST_IMPORT_CSV_URL')('import create -> get round-trip', async (t) => {
+ const created = (await api!
+ .createImport({
+ data_file_url: process.env.CIO_TEST_IMPORT_CSV_URL!,
+ type: 'people',
+ identifier: 'email',
+ name: `sdk-live-${customerId}`,
+ })
+ .catch(() => undefined)) as { import?: { id?: number } } | undefined;
+ const importId = created?.import?.id;
+ if (importId !== undefined) {
+ await api!.getImport(importId).catch(() => undefined);
+ }
+ t.pass();
+});
+
liveTest('track records an event on the profile', async (t) => {
await track!.track(customerId, { name: 'sdk_live_event', data: { run: customerId } });
t.pass();