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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Current SDK methods in `client.ts` (PluggyClient):
| Connectors | `fetchConnectors()`, `fetchConnector()` | GET /connectors |
| Connector Validation | `validateParameters()` | POST /connectors/{id}/validate |
| Items | `createItem()`, `fetchItem()`, `updateItem()`, `deleteItem()` | /items |
| Item listing (opt-in, paid plans; 403 `LIST_ITEMS_FEATURE_NOT_ENABLED` otherwise) | `fetchItemsCursor()` (single page) + `fetchAllItems()` (full sweep) | GET /v2/items |
| Item MFA | `updateItemMFA()` | POST /items/{id}/mfa |
| Item Resources | `fetchItemResources()` | GET /items/{id}/resources |
| SCR (opt-in) | `fetchItemScr()` | GET /items/{id}/scr |
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ await client.updateItemMFA(item.id, {
})
```

#### Listing items (opt-in, paid plans only)

> ⚠️ **Opt-in, paid plans only.** Listing items is disabled by default and is only available to paid-plan teams that have explicitly requested it from Pluggy support. Teams without it enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId` when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use `fetchItem(id)` instead.

`GET /v2/items` returns the team's items newest first, with cursor-based pagination. The only accepted filters are `clientUserId`, `connectorId` and `after`.

- `fetchItemsCursor(options)` — fetch a single page (`{ results, next }`). `next` is a query string such as `?connectorId=201&after=<cursor>`, or `null` on the last page.
- `fetchAllItems(options)` — follow the cursor through every page and return the full list.

```ts
// Single page
const page = await client.fetchItemsCursor({ clientUserId: 'user-123' })

// Next page: read `after` from the `next` query string
if (page.next) {
const after = new URLSearchParams(page.next).get('after')!
const nextPage = await client.fetchItemsCursor({ clientUserId: 'user-123', after })
}

// Or every item at once
const items = await client.fetchAllItems({ connectorId: 201 })
```

### Accounts and transactions (with pagination)

Transactions use **cursor-based pagination** against `GET /v2/transactions`. The SDK exposes two helpers:
Expand Down
53 changes: 53 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Item,
ItemResource,
ItemResourceFilters,
ItemCursorFilters,
CursorPageResponse,
PageResponse,
Parameters,
Expand Down Expand Up @@ -84,6 +85,58 @@ export class PluggyClient extends BaseApi {
return await this.createGetRequest(`items/${id}`)
}

/**
* Fetch a single page of the team's items using cursor-based pagination (`GET /v2/items`),
* newest first.
*
* ⚠️ Opt-in, paid plans only. Listing items is disabled by default and is only available to
* paid-plan teams that have explicitly requested it from Pluggy support. Teams without it
* enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId`
* when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use
* {@link fetchItem} instead.
*
* @param {ItemCursorFilters} options Optional filters (clientUserId, connectorId, after cursor)
* @returns {CursorPageResponse<Item>} object with results and the `next` page query string
* (e.g. `?connectorId=1&after=<cursor>`), or null on the last page
*/
async fetchItemsCursor(options: ItemCursorFilters = {}): Promise<CursorPageResponse<Item>> {
// Only whitelisted params are sent: the endpoint rejects unknown query parameters.
const { clientUserId, connectorId, after } = options
return await this.createGetRequest('v2/items', { clientUserId, connectorId, after })
}

/**
* Fetch all of the team's items, following the cursor across every page (`GET /v2/items`),
* newest first.
*
* ⚠️ Opt-in, paid plans only. Listing items is disabled by default and is only available to
* paid-plan teams that have explicitly requested it from Pluggy support. Teams without it
* enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId`
* when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use
* {@link fetchItem} instead.
*
* @param {ItemCursorFilters} options Optional filters (clientUserId, connectorId)
* @returns {Item[]} an array of all matching items
*/
async fetchAllItems(options: Omit<ItemCursorFilters, 'after'> = {}): Promise<Item[]> {
const firstPage = await this.fetchItemsCursor(options)
const items: Item[] = [...firstPage.results]

let next = firstPage.next

while (next !== null) {
const afterParam = new URL(next, this.baseUrl).searchParams.get('after')
if (!afterParam) {
break
}
const page = await this.fetchItemsCursor({ ...options, after: afterParam })
items.push(...page.results)
next = page.next
}

return items
}

/**
* Check that connector parameters are valid
* @param id The Connector ID
Expand Down
21 changes: 21 additions & 0 deletions src/types/item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,24 @@ export type ItemResourceFilters = PageFilters & {
/** Only return resources with this status. */
status?: ItemResourceStatus
}

/**
* Filters for {@link PluggyClient.fetchItemsCursor} / {@link PluggyClient.fetchAllItems}
* (`GET /v2/items`).
*
* ⚠️ Opt-in, paid plans only. Listing items is disabled by default and is only available to
* paid-plan teams that have explicitly requested it from Pluggy support. Teams without it
* enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId`
* when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use
* `fetchItem(id)` instead.
*
* Only these fields are sent; the endpoint rejects any other query parameter.
*/
export type ItemCursorFilters = {
/** Only return items created with this `clientUserId`. Max 255 characters. */
clientUserId?: string
/** Only return items of this connector. Integer >= 0. */
connectorId?: number
/** Opaque cursor taken from the `after` query parameter of the previous page's `next` field */
after?: string
}
191 changes: 191 additions & 0 deletions tests/itemsCursor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import nock from 'nock'
import { setupAuth } from './utils'
import { PluggyClient } from '../src/client'
import { CursorPageResponse, Item } from '../src/types'

const API_URL = process.env.PLUGGY_API_URL!

const mockItem = (id: string): Item =>
({
id,
clientUserId: 'user-1',
status: 'UPDATED',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
} as unknown as Item)

const mockCursorPage = (ids: string[], next: string | null): CursorPageResponse<Item> => ({
results: ids.map(mockItem),
next,
})

const forbiddenBody = {
code: 403,
codeDescription: 'LIST_ITEMS_FEATURE_NOT_ENABLED',
message: 'This client is not enabled to list its items.',
}

describe('fetchItemsCursor', () => {
beforeEach(() => {
nock.cleanAll()
setupAuth()
})

it('fetches the first page without query params', async () => {
const mock = nock(API_URL)
.get('/v2/items')
.query(query => Object.keys(query).length === 0)
.reply(200, mockCursorPage(['item-1', 'item-2'], null))

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })
const result = await client.fetchItemsCursor()

expect(result.results.map(i => i.id)).toEqual(['item-1', 'item-2'])
expect(result.next).toBeNull()
expect(mock.isDone()).toBeTruthy()
})

it('passes clientUserId and connectorId filters as query params', async () => {
const mock = nock(API_URL)
.get('/v2/items')
.query({ clientUserId: 'user 1+test@example.com', connectorId: '201' })
.reply(
200,
mockCursorPage(['item-3'], '?clientUserId=user-1&connectorId=201&after=cursor-xyz')
)

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })
const result = await client.fetchItemsCursor({
clientUserId: 'user 1+test@example.com',
connectorId: 201,
})

expect(result.results).toHaveLength(1)
expect(result.next).toContain('after=cursor-xyz')
expect(mock.isDone()).toBeTruthy()
})

it('passes the after cursor as query param, preserving base64 characters', async () => {
const cursor = 'eyJpZCI6+abc/def=='
const mock = nock(API_URL)
.get('/v2/items')
.query({ after: cursor })
.reply(200, mockCursorPage(['item-4'], null))

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })
const result = await client.fetchItemsCursor({ after: cursor })

expect(result.results).toHaveLength(1)
expect(mock.isDone()).toBeTruthy()
})

it('does not send query params outside the whitelist', async () => {
const mock = nock(API_URL)
.get('/v2/items')
.query({ connectorId: '1' })
.reply(200, mockCursorPage([], null))

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await client.fetchItemsCursor({ connectorId: 1, pageSize: 50 } as any)

expect(mock.isDone()).toBeTruthy()
})

it('rejects with the API error body when the feature is not enabled (403)', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
nock(API_URL).get('/v2/items').query(true).reply(403, forbiddenBody)

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })

await expect(client.fetchItemsCursor()).rejects.toMatchObject({
code: 403,
codeDescription: 'LIST_ITEMS_FEATURE_NOT_ENABLED',
})
consoleSpy.mockRestore()
})
})

describe('fetchAllItems (cursor-based)', () => {
beforeEach(() => {
nock.cleanAll()
setupAuth()
})

it('returns all results when there is only one page', async () => {
const mock = nock(API_URL)
.get('/v2/items')
.query(query => Object.keys(query).length === 0)
.reply(200, mockCursorPage(['item-1', 'item-2'], null))

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })
const items = await client.fetchAllItems()

expect(items.map(i => i.id)).toEqual(['item-1', 'item-2'])
expect(mock.isDone()).toBeTruthy()
})

it('follows the cursor across three pages forwarding filters on every request', async () => {
const filters = { clientUserId: 'user-1', connectorId: 201 }
const cursor1 = 'cursor+page2=='
const cursor2 = 'cursor/page3'

const mock1 = nock(API_URL)
.get('/v2/items')
.query({ clientUserId: 'user-1', connectorId: '201' })
.reply(
200,
mockCursorPage(
['item-1', 'item-2'],
`?clientUserId=user-1&connectorId=201&after=${encodeURIComponent(cursor1)}`
)
)
const mock2 = nock(API_URL)
.get('/v2/items')
.query({ clientUserId: 'user-1', connectorId: '201', after: cursor1 })
.reply(
200,
mockCursorPage(
['item-3'],
`?clientUserId=user-1&connectorId=201&after=${encodeURIComponent(cursor2)}`
)
)
const mock3 = nock(API_URL)
.get('/v2/items')
.query({ clientUserId: 'user-1', connectorId: '201', after: cursor2 })
.reply(200, mockCursorPage(['item-4'], null))

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })
const items = await client.fetchAllItems(filters)

expect(items.map(i => i.id)).toEqual(['item-1', 'item-2', 'item-3', 'item-4'])
expect(mock1.isDone()).toBeTruthy()
expect(mock2.isDone()).toBeTruthy()
expect(mock3.isDone()).toBeTruthy()
})

it('stops when next has no after param', async () => {
const mock = nock(API_URL)
.get('/v2/items')
.query(true)
.reply(200, mockCursorPage(['item-1'], '?connectorId=1'))

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })
const items = await client.fetchAllItems()

expect(items.map(i => i.id)).toEqual(['item-1'])
expect(mock.isDone()).toBeTruthy()
})

it('propagates the 403 when the feature is not enabled', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
nock(API_URL).get('/v2/items').query(true).reply(403, forbiddenBody)

const client = new PluggyClient({ clientId: '123', clientSecret: '456' })

await expect(client.fetchAllItems()).rejects.toMatchObject({
codeDescription: 'LIST_ITEMS_FEATURE_NOT_ENABLED',
})
consoleSpy.mockRestore()
})
})
Loading