From 3410cbeed03a8d2bfc95b0c5d7fc0f1e1231a610 Mon Sep 17 00:00:00 2001 From: ziptied Date: Wed, 25 Feb 2026 14:10:05 +0900 Subject: [PATCH 1/2] feat(sequences/workflows): add optional pagination to fetch APIs Add optional pagination support to Sequences and Workflows fetch endpoints by introducing GetSequencesParameters and GetWorkflowsParameters types with a page?: number field. Update the BentoSequences.getSequences and BentoWorkflows.getWorkflows methods to accept an optional parameters object and forward it to the client's GET call. Adjust imports and types accordingly. Also expand README docs for Sequences: add a higher-level description, show example usage with Analytics client, demonstrate passing { page } for pagination, and enrich example response and createSequenceEmail documentation to better reflect real-world fields and links to API docs. This enables callers to paginate large installs and documents usage in the SDK README. --- README.md | 132 ++++++++++++++++++-------- __tests__/sequences/sequences.test.ts | 11 +++ __tests__/workflows/workflows.test.ts | 11 +++ src/sdk/sequences/index.ts | 7 +- src/sdk/sequences/types.ts | 4 + src/sdk/workflows/index.ts | 7 +- src/sdk/workflows/types.ts | 4 + 7 files changed, 132 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 1693450..f05b74a 100644 --- a/README.md +++ b/README.md @@ -608,24 +608,33 @@ bento.V1.Tags.createTag({ ### Sequences -Retrieve sequences and their associated email templates. +Sequences power drip campaigns, onboarding flows, and other time-based journeys by chaining multiple email templates with configurable delays. The SDK mirrors the public Sequences API for fetching sequences, creating new sequence emails, and updating template content. Refer to the [Sequences API docs](https://docs.bentonow.com/sequences_api#get-sequences) for full request/response details. #### getSequences -Retrieves all sequences for the site, including their email templates. +Calls `GET /v1/fetch/sequences` and returns every sequence plus the embedded email templates and stats. Pass `{ page }` to paginate through large installs—the SDK appends `site_uuid` automatically. ```javascript -const sequences = await bento.V1.Sequences.getSequences(); -// Returns: -// [ +import { Analytics } from '@bentonow/bento-node-sdk'; + +const analytics = new Analytics({ + authentication: { + publishableKey: process.env.BENTO_PUBLISHABLE_KEY, + secretKey: process.env.BENTO_SECRET_KEY, + }, + siteUuid: process.env.BENTO_SITE_UUID, +}); + +const sequences = await analytics.V1.Sequences.getSequences({ page: 1 }); +// sequences => [ // { -// id: '123', +// id: 'seq-1', // type: 'sequence', // attributes: { // name: 'Welcome Sequence', // created_at: '2024-01-01T00:00:00Z', // email_templates: [ -// { id: 1, subject: 'Welcome!', stats: null }, +// { id: 1, subject: 'Welcome!', stats: { opened: 100, clicked: 50 } }, // { id: 2, subject: 'Getting Started', stats: null } // ] // } @@ -635,39 +644,61 @@ const sequences = await bento.V1.Sequences.getSequences(); #### createSequenceEmail -Creates a new email template inside a sequence. +Wraps [`POST /v1/fetch/sequences/:id/emails/templates`](https://docs.bentonow.com/sequences_api#create-sequence-email) so you can add messages to a sequence via code. Pass the sequence prefix ID (e.g., `sequence_abc123`) plus the subject/HTML and any optional delay/snippet/editor fields. ```javascript -const createdTemplate = await bento.V1.Sequences.createSequenceEmail('sequence_abc123', { +const createdTemplate = await analytics.V1.Sequences.createSequenceEmail('sequence_abc123', { subject: 'Welcome to Bento', html: '

Hello {{ visitor.first_name }}

', delay_interval: 'days', delay_interval_count: 7, inbox_snippet: 'Welcome to the sequence', + editor_choice: 'plain', +}); +``` + +#### updateSequenceEmail + +Sequence emails reuse the Email Templates resource, so updates happen through `analytics.V1.EmailTemplates.updateEmailTemplate` (the same helper documented in the [Email Templates](#email-templates) section). Only `subject` and `html` are patchable today, matching [`PATCH /v1/fetch/emails/templates/:id`](https://docs.bentonow.com/sequences_api#update-sequence-email). + +```javascript +await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 12345, + subject: 'Updated subject', + html: '

Updated HTML

', }); ``` ### Workflows -Retrieve workflows and their associated email templates. +Workflows (a.k.a. Flows) are Bento’s automation engine for welcome journeys, abandoned-cart nudges, re-engagement loops, and other event-driven campaigns. The SDK surfaces the public Workflows API so you can inspect every flow (including the embedded email templates and their stats) straight from Node. See the [Workflows API reference](https://docs.bentonow.com/workflows_api#get-workflows) for the canonical response schema. #### getWorkflows -Retrieves all workflows for the site, including their email templates. +Calls `GET /v1/fetch/workflows` and returns an array of workflows. Pass an optional `page` parameter to paginate through large accounts—the SDK automatically injects your `site_uuid` so you only need to provide the page number. ```javascript -const workflows = await bento.V1.Workflows.getWorkflows(); -// Returns: -// [ +import { Analytics } from '@bentonow/bento-node-sdk'; + +const analytics = new Analytics({ + authentication: { + publishableKey: process.env.BENTO_PUBLISHABLE_KEY, + secretKey: process.env.BENTO_SECRET_KEY, + }, + siteUuid: process.env.BENTO_SITE_UUID, +}); + +const workflows = await analytics.V1.Workflows.getWorkflows({ page: 2 }); +// workflows => [ // { -// id: '456', +// id: 'wf-1', // type: 'workflow', // attributes: { -// name: 'Onboarding Workflow', +// name: 'Abandoned Cart Recovery', // created_at: '2024-01-01T00:00:00Z', // email_templates: [ -// { id: 3, subject: 'Step 1', stats: null }, -// { id: 4, subject: 'Step 2', stats: null } +// { id: 3, subject: 'Reminder #1', stats: { opened: 42, clicked: 10 } }, +// { id: 4, subject: 'Reminder #2', stats: null } // ] // } // } @@ -676,38 +707,63 @@ const workflows = await bento.V1.Workflows.getWorkflows(); ### Email Templates -Retrieve and update email templates used in sequences and workflows. +Retrieve and update email templates used in sequences and workflows. Both helpers call the public Email Templates API (`GET /v1/fetch/emails/templates/:id` and `PATCH /v1/fetch/emails/templates/:id`), and the SDK automatically injects your `site_uuid` and authentication headers. See the [Email Templates API docs](https://docs.bentonow.com/email_templates_api#get-email-template) for the canonical contract. #### getEmailTemplate -Retrieves a single email template by ID. +Retrieves a single email template by ID and returns `null` when the Bento API responds with an empty payload. Use this to surface subject lines, HTML, and performance stats inside your own tooling. ```javascript -const template = await bento.V1.EmailTemplates.getEmailTemplate({ id: 123 }); -// Returns: -// { -// id: '123', -// type: 'email_template', -// attributes: { -// name: 'Welcome Email', -// subject: 'Welcome to our service!', -// html: '

Hello {{ name }}, welcome!

', -// created_at: '2024-01-01T00:00:00Z', -// stats: null -// } -// } +import { Analytics } from '@bentonow/bento-node-sdk'; + +const analytics = new Analytics({ + authentication: { + publishableKey: process.env.BENTO_PUBLISHABLE_KEY, + secretKey: process.env.BENTO_SECRET_KEY, + }, + siteUuid: process.env.BENTO_SITE_UUID, +}); + +const template = await analytics.V1.EmailTemplates.getEmailTemplate({ id: 123 }); +if (!template) { + console.log('Template not found'); +} else { + console.log(template.attributes.subject, template.attributes.stats); +} ``` #### updateEmailTemplate -Updates an email template's subject and/or HTML content. +Updates an email template's subject and/or HTML content via [`PATCH /v1/fetch/emails/templates/:id`](https://docs.bentonow.com/email_templates_api#update-email-template). Only pass the fields you want to change; omitted fields stay untouched. The helper returns the updated template (`null` if Bento responds empty) and bubbles up standard SDK errors such as `NotAuthorizedError`, `RateLimitedError`, or `RequestTimeoutError`. ```javascript -const updatedTemplate = await bento.V1.EmailTemplates.updateEmailTemplate({ - id: 123, - subject: 'Updated Subject Line', - html: '

Updated HTML content with {{ name }}

', +import { Analytics, NotAuthorizedError } from '@bentonow/bento-node-sdk'; + +const analytics = new Analytics({ + authentication: { + publishableKey: process.env.BENTO_PUBLISHABLE_KEY, + secretKey: process.env.BENTO_SECRET_KEY, + }, + siteUuid: process.env.BENTO_SITE_UUID, }); + +try { + const updatedTemplate = await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 123, + subject: 'Updated Subject Line', + html: '

Updated HTML content with {{ name }}

', + }); + + if (updatedTemplate) { + console.log(updatedTemplate.attributes.subject); + } +} catch (error) { + if (error instanceof NotAuthorizedError) { + console.error('Check your Bento credentials or site permissions.'); + } else { + throw error; + } +} ``` For detailed information on each module, refer to the [SDK Documentation](https://docs.bentonow.com/subscribers). diff --git a/__tests__/sequences/sequences.test.ts b/__tests__/sequences/sequences.test.ts index 73b11df..5491073 100644 --- a/__tests__/sequences/sequences.test.ts +++ b/__tests__/sequences/sequences.test.ts @@ -61,6 +61,17 @@ describe('BentoSequences', () => { expect(lastFetchMethod).toBe('GET'); }); + test('passes pagination parameters in query string', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Sequences.getSequences({ page: 5 }); + + expect(lastFetchUrl).toContain('/fetch/sequences'); + const url = new URL(lastFetchUrl!); + expect(url.searchParams.get('page')).toBe('5'); + expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid); + }); + test('returns empty array when response is empty object', async () => { setupMockFetch({}); diff --git a/__tests__/workflows/workflows.test.ts b/__tests__/workflows/workflows.test.ts index c37e72a..a488c3a 100644 --- a/__tests__/workflows/workflows.test.ts +++ b/__tests__/workflows/workflows.test.ts @@ -61,6 +61,17 @@ describe('BentoWorkflows', () => { expect(lastFetchMethod).toBe('GET'); }); + test('passes pagination parameters in query string', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Workflows.getWorkflows({ page: 3 }); + + expect(lastFetchUrl).toContain('/fetch/workflows'); + const url = new URL(lastFetchUrl!); + expect(url.searchParams.get('page')).toBe('3'); + expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid); + }); + test('returns empty array when response is empty object', async () => { setupMockFetch({}); diff --git a/src/sdk/sequences/index.ts b/src/sdk/sequences/index.ts index 7b7f9ab..76d6a91 100644 --- a/src/sdk/sequences/index.ts +++ b/src/sdk/sequences/index.ts @@ -1,7 +1,7 @@ import type { BentoClient } from '../client'; import type { DataResponse } from '../client/types'; import type { EmailTemplate } from '../email-templates/types'; -import type { CreateSequenceEmailParameters, Sequence } from './types'; +import type { CreateSequenceEmailParameters, GetSequencesParameters, Sequence } from './types'; export class BentoSequences { private readonly _url = '/fetch/sequences'; @@ -11,10 +11,11 @@ export class BentoSequences { /** * Returns all of the sequences for the site, including their email templates. * + * @param parameters Optional pagination parameters (e.g., { page: 2 }) * @returns Promise\ */ - public async getSequences(): Promise { - const result = await this._client.get>(this._url); + public async getSequences(parameters: GetSequencesParameters = {}): Promise { + const result = await this._client.get>(this._url, parameters); if (!result || Object.keys(result).length === 0) return []; return result.data ?? []; diff --git a/src/sdk/sequences/types.ts b/src/sdk/sequences/types.ts index 0c1f94b..4469dbe 100644 --- a/src/sdk/sequences/types.ts +++ b/src/sdk/sequences/types.ts @@ -23,6 +23,10 @@ export type Sequence = BaseEntity; export type SequenceDelayInterval = 'minutes' | 'hours' | 'days' | 'months'; +export type GetSequencesParameters = { + page?: number; +}; + export type CreateSequenceEmailParameters = { subject: string; html: string; diff --git a/src/sdk/workflows/index.ts b/src/sdk/workflows/index.ts index 6d8b8a3..3a25449 100644 --- a/src/sdk/workflows/index.ts +++ b/src/sdk/workflows/index.ts @@ -1,6 +1,6 @@ import type { BentoClient } from '../client'; import type { DataResponse } from '../client/types'; -import type { Workflow } from './types'; +import type { GetWorkflowsParameters, Workflow } from './types'; export class BentoWorkflows { private readonly _url = '/fetch/workflows'; @@ -10,10 +10,11 @@ export class BentoWorkflows { /** * Returns all of the workflows for the site, including their email templates. * + * @param parameters Optional pagination parameters (e.g., { page: 2 }) * @returns Promise\ */ - public async getWorkflows(): Promise { - const result = await this._client.get>(this._url); + public async getWorkflows(parameters: GetWorkflowsParameters = {}): Promise { + const result = await this._client.get>(this._url, parameters); if (!result || Object.keys(result).length === 0) return []; return result.data ?? []; diff --git a/src/sdk/workflows/types.ts b/src/sdk/workflows/types.ts index e08de4e..0a26656 100644 --- a/src/sdk/workflows/types.ts +++ b/src/sdk/workflows/types.ts @@ -19,3 +19,7 @@ export type WorkflowAttributes = { }; export type Workflow = BaseEntity; + +export type GetWorkflowsParameters = { + page?: number; +}; From 3cea3e268d37537d5e7be3b36481a50caad55bec Mon Sep 17 00:00:00 2001 From: ziptied Date: Wed, 25 Feb 2026 14:19:10 +0900 Subject: [PATCH 2/2] fix(client): omit null/undefined query params when building URL Ensure query parameters with null or undefined values are skipped when serializing request bodies into URLSearchParams. This prevents literal "null" or empty parameters from being sent and aligns behavior with expected API semantics. Add tests and test helpers to verify the change: - export lastFetchUrl from mockFetch helper to inspect the request URL. - add a test that confirms undefined SDK options (page) are omitted from the query string. - assert site_uuid is correctly set and not the string "null". This fixes incorrect query parameter serialization and adds coverage to prevent regressions. --- __tests__/client/client.test.ts | 18 +++++++++++++++++- src/sdk/client/index.ts | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/__tests__/client/client.test.ts b/__tests__/client/client.test.ts index 694431b..5851a7d 100644 --- a/__tests__/client/client.test.ts +++ b/__tests__/client/client.test.ts @@ -1,7 +1,12 @@ import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { setupMockFetch, lastFetchSignal, resetMockFetchTracking } from '../helpers/mockFetch'; +import { + setupMockFetch, + lastFetchSignal, + lastFetchUrl, + resetMockFetchTracking, +} from '../helpers/mockFetch'; import { NotAuthorizedError, RateLimitedError, RequestTimeoutError } from '../../src'; describe('BentoClient', () => { @@ -215,6 +220,17 @@ describe('BentoClient', () => { expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid); }); + test('omits undefined query parameters when forwarding SDK options', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Sequences.getSequences({ page: undefined }); + + expect(lastFetchUrl).not.toBeNull(); + const url = new URL(lastFetchUrl!); + expect(url.searchParams.has('page')).toBe(false); + expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid); + }); + test('initializes with custom timeout', () => { const customOptions = { ...mockOptions, diff --git a/src/sdk/client/index.ts b/src/sdk/client/index.ts index 3556f1e..16817c7 100644 --- a/src/sdk/client/index.ts +++ b/src/sdk/client/index.ts @@ -255,6 +255,7 @@ export class BentoClient { const queryParameters = new URLSearchParams(); for (const [key, value] of Object.entries(body)) { + if (value === undefined || value === null) continue; queryParameters.append(key, String(value)); }