Skip to content

Commit 9b068c3

Browse files
committed
feat(stripe): add optional subscription pagination cursors
1 parent 3bc192f commit 9b068c3

9 files changed

Lines changed: 251 additions & 12 deletions

File tree

‎apps/docs/content/docs/integrations/stripe.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,7 +1299,7 @@ Resume a subscription that was scheduled for cancellation
12991299

13001300
### Stripe List Subscriptions
13011301

1302-
List all subscriptions
1302+
List one page of subscriptions. Follow starting_after while metadata.has_more is true.
13031303

13041304
#### Input
13051305

@@ -1309,6 +1309,8 @@ List all subscriptions
13091309
| `limit` | number | No | Number of results to return \(default 10, max 100\) |
13101310
| `customer` | string | No | Filter by customer ID |
13111311
| `status` | string | No | Filter by status \(active, past_due, unpaid, canceled, incomplete, incomplete_expired, trialing, all\) |
1312+
| `starting_after` | string | No | Next-page cursor: last subscription ID from the previous page. Do not combine with ending_before. |
1313+
| `ending_before` | string | No | Previous-page cursor: first subscription ID from the current page. Do not combine with starting_after. |
13121314
| `price` | string | No | Filter by price ID |
13131315

13141316
#### Output
@@ -1378,14 +1380,15 @@ List all subscriptions
13781380

13791381
### Stripe Search Subscriptions
13801382

1381-
Search for subscriptions using query syntax
1383+
Search one page of subscriptions using query syntax. Pass metadata.next_page as page to continue.
13821384

13831385
#### Input
13841386

13851387
| Parameter | Type | Required | Description |
13861388
| --------- | ---- | -------- | ----------- |
13871389
| `apiKey` | string | Yes | Stripe API key \(secret key\) |
13881390
| `query` | string | Yes | Search query \(e.g., "status:'active' AND customer:'cus_xxx'"\) |
1391+
| `page` | string | No | Pagination token from metadata.next_page. Omit for the first page and keep the same query. |
13891392
| `limit` | number | No | Number of results to return \(default 10, max 100\) |
13901393

13911394
#### Output
@@ -1452,6 +1455,7 @@ Search for subscriptions using query syntax
14521455
| `metadata` | json | Search metadata |
14531456
| ↳ `count` | number | Number of items returned |
14541457
| ↳ `has_more` | boolean | Whether more items exist beyond this page |
1458+
| ↳ `next_page` | string | Token for the next page of search results, or null when there are no more results |
14551459

14561460
### Stripe Create Invoice
14571461

‎apps/sim/blocks/blocks/stripe.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,30 @@ export const StripeBlock: BlockConfig<StripeResponse> = {
708708
},
709709
required: true,
710710
},
711+
{
712+
id: 'starting_after',
713+
title: 'Starting After',
714+
type: 'short-input',
715+
placeholder: 'Last subscription ID from the previous page',
716+
condition: { field: 'operation', value: 'list_subscriptions' },
717+
mode: 'advanced',
718+
},
719+
{
720+
id: 'ending_before',
721+
title: 'Ending Before',
722+
type: 'short-input',
723+
placeholder: 'First subscription ID from the current page',
724+
condition: { field: 'operation', value: 'list_subscriptions' },
725+
mode: 'advanced',
726+
},
727+
{
728+
id: 'page',
729+
title: 'Search Page',
730+
type: 'short-input',
731+
placeholder: 'Token from metadata.next_page; omit for the first page',
732+
condition: { field: 'operation', value: 'search_subscriptions' },
733+
mode: 'advanced',
734+
},
711735
// Additional filters for specific list operations
712736
{
713737
id: 'status',
@@ -976,6 +1000,9 @@ export const StripeBlock: BlockConfig<StripeResponse> = {
9761000
recurring: { type: 'json', description: 'Recurring billing configuration' },
9771001
// List/Search inputs
9781002
limit: { type: 'number', description: 'Maximum results to return' },
1003+
starting_after: { type: 'string', description: 'Next-page subscription ID cursor' },
1004+
ending_before: { type: 'string', description: 'Previous-page subscription ID cursor' },
1005+
page: { type: 'string', description: 'Subscription search token from metadata.next_page' },
9791006
query: { type: 'string', description: 'Search query' },
9801007
status: { type: 'string', description: 'Status filter' },
9811008
type: { type: 'string', description: 'Event type filter' },

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎apps/sim/tools/generated/tool-outputs.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎apps/sim/tools/index.test.ts‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ import { searchIssuesV2Tool } from '@/tools/github/search_issues'
7575
import { memoryAddTool } from '@/tools/memory/add'
7676
import { createInternalToolOperationInput } from '@/tools/operation-input'
7777
import { slackListsItemsListTool } from '@/tools/slack_lists/items_list'
78+
import { stripeListSubscriptionsTool } from '@/tools/stripe/list_subscriptions'
79+
import { stripeSearchSubscriptionsTool } from '@/tools/stripe/search_subscriptions'
7880
import { getCallerIdentityTool } from '@/tools/sts/get_caller_identity'
7981
import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows'
8082
import type { InternalToolConfig, ToolResponse } from '@/tools/types'
@@ -183,6 +185,8 @@ vi.mock('@/executor/handlers/workflow/custom-block-tool-runner', () => ({
183185
// Mock the tools registry to avoid loading the full 4500+ line registry file.
184186
// Only the tools actually exercised in tests are provided.
185187
const mockRegistryTools: Record<string, any> = {
188+
stripe_list_subscriptions: stripeListSubscriptionsTool,
189+
stripe_search_subscriptions: stripeSearchSubscriptionsTool,
186190
slack_lists_items_list: slackListsItemsListTool,
187191
github_search_issues_v2: searchIssuesV2Tool,
188192
bitbucket_get_pipeline_step_log: bitbucketGetPipelineStepLogTool,
@@ -2862,6 +2866,163 @@ describe('executeTool Function', () => {
28622866
})
28632867
})
28642868

2869+
describe('Stripe subscription pagination through tool execution', () => {
2870+
beforeEach(() => {
2871+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' })
2872+
mockSecureFetchWithPinnedIP.mockReset()
2873+
})
2874+
2875+
it('retains first-page filters and returns one page even when more subscriptions exist', async () => {
2876+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
2877+
toSecureFetchResponse(
2878+
Response.json({
2879+
object: 'list',
2880+
data: [{ id: 'sub_first' }, { id: 'sub_last' }],
2881+
has_more: true,
2882+
})
2883+
)
2884+
)
2885+
2886+
const result = await executeTool('stripe_list_subscriptions', {
2887+
apiKey: 'sk_test_pagination',
2888+
limit: 100,
2889+
customer: 'cus_example',
2890+
status: 'active',
2891+
price: 'price_example',
2892+
})
2893+
2894+
expect(result).toMatchObject({
2895+
success: true,
2896+
output: {
2897+
subscriptions: [{ id: 'sub_first' }, { id: 'sub_last' }],
2898+
metadata: { count: 2, has_more: true },
2899+
},
2900+
})
2901+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledExactlyOnceWith(
2902+
'https://api.stripe.com/v1/subscriptions?limit=100&customer=cus_example&status=active&price=price_example',
2903+
'93.184.216.34',
2904+
expect.objectContaining({
2905+
method: 'GET',
2906+
headers: expect.objectContaining({ authorization: 'Bearer sk_test_pagination' }),
2907+
})
2908+
)
2909+
})
2910+
2911+
it.each(['starting_after', 'ending_before'] as const)(
2912+
'retrieves the requested %s page without interpreting cursor characters as filters',
2913+
async (direction) => {
2914+
const cursor = 'sub_cursor&status=canceled'
2915+
mockSecureFetchWithPinnedIP.mockImplementationOnce(async (url) => {
2916+
const request = new URL(url)
2917+
const valid =
2918+
request.pathname === '/v1/subscriptions' &&
2919+
request.searchParams.get(direction) === cursor &&
2920+
request.searchParams.get('status') === 'active'
2921+
return toSecureFetchResponse(
2922+
valid
2923+
? Response.json({ object: 'list', data: [{ id: 'sub_next' }], has_more: false })
2924+
: Response.json(
2925+
{ error: { message: 'Unexpected pagination request' } },
2926+
{ status: 400 }
2927+
)
2928+
)
2929+
})
2930+
2931+
const result = await executeTool('stripe_list_subscriptions', {
2932+
apiKey: 'sk_test_pagination',
2933+
status: 'active',
2934+
[direction]: cursor,
2935+
})
2936+
2937+
expect(result).toMatchObject({
2938+
success: true,
2939+
output: { subscriptions: [{ id: 'sub_next' }], metadata: { count: 1, has_more: false } },
2940+
})
2941+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledOnce()
2942+
}
2943+
)
2944+
2945+
it('rejects conflicting list cursors before contacting Stripe', async () => {
2946+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
2947+
toSecureFetchResponse(Response.json({ object: 'list', data: [], has_more: false }))
2948+
)
2949+
2950+
const result = await executeTool('stripe_list_subscriptions', {
2951+
apiKey: 'sk_test_pagination',
2952+
starting_after: 'sub_last',
2953+
ending_before: 'sub_first',
2954+
})
2955+
2956+
expect(result).toMatchObject({
2957+
success: false,
2958+
error: expect.stringContaining('Provide either starting_after or ending_before, not both'),
2959+
})
2960+
expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
2961+
})
2962+
2963+
it('round-trips the search continuation token and exposes the terminal page', async () => {
2964+
const query = "status:'active'"
2965+
const nextPage = 'opaque/search+cursor=='
2966+
mockSecureFetchWithPinnedIP.mockImplementation(async (url) => {
2967+
const request = new URL(url)
2968+
if (
2969+
request.pathname !== '/v1/subscriptions/search' ||
2970+
request.searchParams.get('query') !== query ||
2971+
request.searchParams.get('limit') !== '100'
2972+
) {
2973+
return toSecureFetchResponse(
2974+
Response.json({ error: { message: 'Search filters changed' } }, { status: 400 })
2975+
)
2976+
}
2977+
const page = request.searchParams.get('page')
2978+
if (page === null) {
2979+
return toSecureFetchResponse(
2980+
Response.json({
2981+
object: 'search_result',
2982+
data: [{ id: 'sub_first' }],
2983+
has_more: true,
2984+
next_page: nextPage,
2985+
})
2986+
)
2987+
}
2988+
return toSecureFetchResponse(
2989+
page === nextPage
2990+
? Response.json({ object: 'search_result', data: [{ id: 'sub_last' }], has_more: false })
2991+
: Response.json({ error: { message: 'Invalid search page' } }, { status: 400 })
2992+
)
2993+
})
2994+
2995+
const first = await executeTool('stripe_search_subscriptions', {
2996+
apiKey: 'sk_test_pagination',
2997+
query,
2998+
limit: 100,
2999+
})
3000+
expect(first).toMatchObject({
3001+
success: true,
3002+
output: {
3003+
subscriptions: [{ id: 'sub_first' }],
3004+
metadata: { count: 1, has_more: true, next_page: nextPage },
3005+
},
3006+
})
3007+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledOnce()
3008+
3009+
const last = await executeTool('stripe_search_subscriptions', {
3010+
apiKey: 'sk_test_pagination',
3011+
query,
3012+
limit: 100,
3013+
page: first.output.metadata.next_page,
3014+
})
3015+
expect(last).toMatchObject({
3016+
success: true,
3017+
output: {
3018+
subscriptions: [{ id: 'sub_last' }],
3019+
metadata: { count: 1, has_more: false, next_page: null },
3020+
},
3021+
})
3022+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2)
3023+
})
3024+
})
3025+
28653026
describe('Internal Route Trust', () => {
28663027
let cleanupEnvVars: () => void
28673028

‎apps/sim/tools/stripe/list_subscriptions.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export const stripeListSubscriptionsTool: ToolConfig<
88
> = {
99
id: 'stripe_list_subscriptions',
1010
name: 'Stripe List Subscriptions',
11-
description: 'List all subscriptions',
11+
description:
12+
'List one page of subscriptions. Follow starting_after while metadata.has_more is true.',
1213
version: '1.0.0',
1314

1415
params: {
@@ -37,6 +38,20 @@ export const stripeListSubscriptionsTool: ToolConfig<
3738
description:
3839
'Filter by status (active, past_due, unpaid, canceled, incomplete, incomplete_expired, trialing, all)',
3940
},
41+
starting_after: {
42+
type: 'string',
43+
required: false,
44+
visibility: 'user-or-llm',
45+
description:
46+
'Next-page cursor: last subscription ID from the previous page. Do not combine with ending_before.',
47+
},
48+
ending_before: {
49+
type: 'string',
50+
required: false,
51+
visibility: 'user-or-llm',
52+
description:
53+
'Previous-page cursor: first subscription ID from the current page. Do not combine with starting_after.',
54+
},
4055
price: {
4156
type: 'string',
4257
required: false,
@@ -47,11 +62,16 @@ export const stripeListSubscriptionsTool: ToolConfig<
4762

4863
request: {
4964
url: (params) => {
65+
if (params.starting_after && params.ending_before) {
66+
throw new Error('Provide either starting_after or ending_before, not both')
67+
}
5068
const url = new URL('https://api.stripe.com/v1/subscriptions')
5169
if (params.limit) url.searchParams.append('limit', params.limit.toString())
5270
if (params.customer) url.searchParams.append('customer', params.customer)
5371
if (params.status) url.searchParams.append('status', params.status)
5472
if (params.price) url.searchParams.append('price', params.price)
73+
if (params.starting_after) url.searchParams.append('starting_after', params.starting_after)
74+
if (params.ending_before) url.searchParams.append('ending_before', params.ending_before)
5575
return url.toString()
5676
},
5777
method: 'GET',

‎apps/sim/tools/stripe/search_subscriptions.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import type { SearchSubscriptionsParams, SubscriptionListResponse } from '@/tools/stripe/types'
1+
import type { SearchSubscriptionsParams, SubscriptionSearchResponse } from '@/tools/stripe/types'
22
import { LIST_METADATA_OUTPUT_PROPERTIES, SUBSCRIPTION_OUTPUT } from '@/tools/stripe/types'
33
import type { ToolConfig } from '@/tools/types'
44

55
export const stripeSearchSubscriptionsTool: ToolConfig<
66
SearchSubscriptionsParams,
7-
SubscriptionListResponse
7+
SubscriptionSearchResponse
88
> = {
99
id: 'stripe_search_subscriptions',
1010
name: 'Stripe Search Subscriptions',
11-
description: 'Search for subscriptions using query syntax',
11+
description:
12+
'Search one page of subscriptions using query syntax. Pass metadata.next_page as page to continue.',
1213
version: '1.0.0',
1314

1415
params: {
@@ -24,6 +25,13 @@ export const stripeSearchSubscriptionsTool: ToolConfig<
2425
visibility: 'user-or-llm',
2526
description: "Search query (e.g., \"status:'active' AND customer:'cus_xxx'\")",
2627
},
28+
page: {
29+
type: 'string',
30+
required: false,
31+
visibility: 'user-or-llm',
32+
description:
33+
'Pagination token from metadata.next_page. Omit for the first page and keep the same query.',
34+
},
2735
limit: {
2836
type: 'number',
2937
required: false,
@@ -37,6 +45,7 @@ export const stripeSearchSubscriptionsTool: ToolConfig<
3745
const url = new URL('https://api.stripe.com/v1/subscriptions/search')
3846
url.searchParams.append('query', params.query)
3947
if (params.limit) url.searchParams.append('limit', params.limit.toString())
48+
if (params.page) url.searchParams.append('page', params.page)
4049
return url.toString()
4150
},
4251
method: 'GET',
@@ -55,6 +64,7 @@ export const stripeSearchSubscriptionsTool: ToolConfig<
5564
metadata: {
5665
count: (data.data || []).length,
5766
has_more: data.has_more || false,
67+
next_page: data.next_page ?? null,
5868
},
5969
},
6070
}
@@ -69,7 +79,15 @@ export const stripeSearchSubscriptionsTool: ToolConfig<
6979
metadata: {
7080
type: 'json',
7181
description: 'Search metadata',
72-
properties: LIST_METADATA_OUTPUT_PROPERTIES,
82+
properties: {
83+
...LIST_METADATA_OUTPUT_PROPERTIES,
84+
next_page: {
85+
type: 'string',
86+
nullable: true,
87+
description:
88+
'Token for the next page of search results, or null when there are no more results',
89+
},
90+
},
7391
},
7492
},
7593
}

0 commit comments

Comments
 (0)