From 7c70dadb3b0a2e5a04cbfc187197237baf2b760e Mon Sep 17 00:00:00 2001 From: Mike Engel Date: Wed, 15 Jul 2026 12:34:16 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20App=20API=20deliverability=20?= =?UTF-8?q?methods=20=E2=80=94=20ESP=20suppressions=20&=20reporting=20webh?= =?UTF-8?q?ooks=20(CDP-6276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 12 (stacked on Batch 11). Adds 5 ESP email-suppression methods and 5 reporting-webhook methods to APIClient. Contracts verified against the backend, not the OpenAPI spec: - Suppression categories are blocks/bounces/spam_reports/invalid_emails; email is a path segment (URL-encoded). getSuppressions is offset-based; getDomainSuppressions is cursor-based (start). create returns a body, delete returns 204. - Reporting webhooks use integer ids; the create body's URL field is 'endpoint'; DELETE returns 204. Models the standard webhook type. Includes unit tests (100% coverage), live round-trip coverage, and docs. --- docs/app.md | 143 +++++++++++++++++++++++ lib/api.ts | 240 +++++++++++++++++++++++++++++++++++++++ test/api.ts | 89 +++++++++++++++ test/integration/live.ts | 39 +++++++ 4 files changed, 511 insertions(+) diff --git a/docs/app.md b/docs/app.md index 082f653..1d3e6dc 100644 --- a/docs/app.md +++ b/docs/app.md @@ -1863,3 +1863,146 @@ api.updateCollectionContent(9, [ - **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) diff --git a/lib/api.ts b/lib/api.ts index abe2b4c..eeb23ba 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -519,6 +519,57 @@ export type CollectionUpdate = { 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; +}; + type APIDefaults = RequestDefaults & { region: Region; url?: string; retry?: Partial }; type Recipients = Record; @@ -3331,6 +3382,195 @@ export class APIClient { 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)}`); + } } export { diff --git a/test/api.ts b/test/api.ts index 2c1b9e3..45053f0 100644 --- a/test/api.ts +++ b/test/api.ts @@ -2167,3 +2167,92 @@ test('#updateCollectionContent: puts the raw row array and validates', (t) => { 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`)); +}); diff --git a/test/integration/live.ts b/test/integration/live.ts index 216fc51..f249be8 100644 --- a/test/integration/live.ts +++ b/test/integration/live.ts @@ -447,6 +447,45 @@ liveTest('collection create -> read -> content -> delete round-trip', async (t) 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('track records an event on the profile', async (t) => { await track!.track(customerId, { name: 'sdk_live_event', data: { run: customerId } }); t.pass(); From d4b9893598cdde45488d7d687fb89bed29bad5fd Mon Sep 17 00:00:00 2001 From: Mike Engel Date: Wed, 15 Jul 2026 15:09:10 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20add=20App=20API=20content=20&=20sen?= =?UTF-8?q?der=20utility=20methods=20=E2=80=94=20snippets,=20sender=20iden?= =?UTF-8?q?tities,=20messages=20(#244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/app.md | 218 +++++++++++++++++ lib/api.ts | 377 ++++++++++++++++++++++++++++++ test/api.ts | 171 ++++++++++++++ test/integration/live.env.example | 2 + test/integration/live.ts | 56 +++++ 5 files changed, 824 insertions(+) diff --git a/docs/app.md b/docs/app.md index 1d3e6dc..84c5128 100644 --- a/docs/app.md +++ b/docs/app.md @@ -2006,3 +2006,221 @@ 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 eeb23ba..777f2f7 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -570,6 +570,114 @@ export type ReportingWebhookUpdate = { 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; @@ -3571,6 +3679,275 @@ export class APIClient { 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 45053f0..1a740a9 100644 --- a/test/api.ts +++ b/test/api.ts @@ -2256,3 +2256,174 @@ test('#deleteReportingWebhook: deletes a webhook and validates', (t) => { 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 f249be8..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. @@ -486,6 +487,61 @@ liveTest('reporting webhook create -> read -> update -> delete round-trip', asyn 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();