From 65d4546f76b7e1735499d999f2328e21c91d4d8a Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 19 Aug 2026 15:56:36 -0400 Subject: [PATCH 1/4] Add --expires-at support to spend-request create Mirrors the mint PR (stripe-internal/mint#2484603) that lets allow-listed OAuth clients request a spend request expiration up to 7 days out instead of the default 12 hours, for extended/repeat-use agent scenarios. Co-Authored-By: Claude Sonnet 5 Committed-By-Agent: claude --- CLAUDE.md | 1 + README.md | 11 +++- packages/cli/src/__tests__/cli.test.ts | 52 +++++++++++++++++++ .../cli/src/commands/spend-request/index.tsx | 1 + .../cli/src/commands/spend-request/schema.ts | 7 +++ packages/sdk/src/resources/interfaces.ts | 1 + packages/sdk/src/types/index.ts | 1 + skills/create-payment-credential/SKILL.md | 4 +- 8 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1472b0..3e6a8fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,7 @@ Key input field notes: - `--approval-detail` — optional JSON object (MCP/agent) or JSON string (CLI) with approval details for delegated flows. Required fields: `approved_at` (unix timestamp int), `approval_method` (`click`|`programmatic`|`voice`), `app_name`, `external_user_id`. Optional: `ip_address`, `user_agent`, `device_type` (`mobile`|`web`), `agent_log_id`, `external_user_name`, `external_session_id`, `authentication_method` (`biometric_face`|`biometric_fingerprint`|`passkey`). Sent as `approval_details` in the API request body. - `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (ISO date string — when the card expires/stops working) - `--output-file ` on `retrieve` or `create` writes full card credentials to a local file (0600 permissions) and redacts card data in stdout. `--force` allows overwriting an existing file. +- `--expires-at ` (create only) overrides the default 12-hour spend request expiration; must be 3 hours to 7 days in the future. Requires an allow-listed OAuth client — the server 400s with `"expires_at is not supported for this client"` otherwise. Not supported on `update`. The spend request response includes `expires_at` (epoch seconds) once set. ### mpp pay diff --git a/README.md b/README.md index 51b21b2..e7808f1 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,15 @@ link-cli spend-request create ... \ In MCP/agent mode, pass `metadata` as a structured `{ key: value }` object. +#### Expiration + +By default, a spend request expires 12 hours after creation. Pass `--expires-at` with a unix timestamp (seconds) to request a longer or shorter expiration — must be between 3 hours and 7 days in the future. This requires an allow-listed OAuth client; unlisted clients get a 400 error. + +```bash +link-cli spend-request create ... \ + --expires-at 1720100000 +``` + #### Credential types By default, a spend request provisions a virtual card. For merchants that support the [Machine Payments Protocol](https://mpp.dev) (HTTP 402) and the Stripe payment method, instead pass `--credential-type "shared_payment_token"`. @@ -287,7 +296,7 @@ Link Pay Token requests require `execution_method=link_pay_token` and the DOM-derived `merchant_account_id`, and Link supplies their canonical merchant identity. -**Constraints:** `context` must be at least 100 characters; `amount` must not exceed 50000 (cents); `currency` must be a 3-letter ISO code. The user has 10 minutes from when approval is requested to approve. Approved credentials (card or SPT) are valid for 12 hours from spend request creation. +**Constraints:** `context` must be at least 100 characters; `amount` must not exceed 50000 (cents); `currency` must be a 3-letter ISO code. The user has 10 minutes from when approval is requested to approve. Approved credentials (card or SPT) are valid for 12 hours from spend request creation by default — pass `--expires-at` on create to override (3 hours to 7 days; requires an allow-listed OAuth client). **Test mode:** Pass `--test` to create a testmode SpendRequest. A testmode SpendRequest will return test payment credentials (e.g test card `4000009990001984`) rather than a real payment credential. Testmode SpendRequests will not charge the underlying payment method of the SpendRequest. This is useful for development and integration testing without real payment methods. ```bash diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index ea87ea0..e717186 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -556,6 +556,58 @@ describe('production mode', () => { expect(sentBody.metadata).toBeUndefined(); }); + it('sends expires_at in POST body when --expires-at is used', async () => { + setNextResponse(200, BASE_REQUEST); + + const result = await runProdCli( + 'spend-request', + 'create', + '--payment-method-id', + 'pd_prod_test', + '--merchant-name', + 'Test Merchant', + '--merchant-url', + 'https://example.com', + '--context', + VALID_CONTEXT, + '--amount', + '5000', + '--expires-at', + '1720100000', + '--no-request-approval', + '--json', + ); + + expect(result.exitCode).toBe(0); + const sentBody = JSON.parse(lastRequest.body); + expect(sentBody.expires_at).toBe(1720100000); + }); + + it('does not include expires_at in POST body when --expires-at is omitted', async () => { + setNextResponse(200, BASE_REQUEST); + + const result = await runProdCli( + 'spend-request', + 'create', + '--payment-method-id', + 'pd_prod_test', + '--merchant-name', + 'Test Merchant', + '--merchant-url', + 'https://example.com', + '--context', + VALID_CONTEXT, + '--amount', + '5000', + '--no-request-approval', + '--json', + ); + + expect(result.exitCode).toBe(0); + const sentBody = JSON.parse(lastRequest.body); + expect(sentBody.expires_at).toBeUndefined(); + }); + it('sends test flag in POST body when --test is used', async () => { setNextResponse(200, BASE_REQUEST); diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 3b640c9..75fc35c 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -270,6 +270,7 @@ export function createSpendRequestCli( approve: opts.approve ? true : undefined, approval_details: approvalDetails, metadata, + expires_at: opts.expiresAt, }; const outputFile = opts.outputFile; diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index 360de8b..a65614f 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -98,6 +98,13 @@ export const createOptions = z.object({ .describe( 'Metadata key:value pair (repeatable). Attaches arbitrary string data to the spend request. Max 50 keys, key <= 40 chars, value <= 500 chars. Example: "order_id:ord_123"', ), + expiresAt: z.coerce + .number() + .int() + .optional() + .describe( + 'Unix timestamp (seconds) when the spend request should expire. Must be 3 hours to 7 days in the future. Omit for the default 12-hour expiration. Requires an allow-listed OAuth client — the server returns a 400 error otherwise.', + ), }); export const listOptions = z.object({ diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index cd69673..852d67f 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -69,6 +69,7 @@ export interface CreateSpendRequestParams { approve?: boolean; approval_details?: ApprovalDetail; metadata?: Record; + expires_at?: number; } export interface UpdateSpendRequestParams { diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 8335a1b..f4b03c4 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -174,6 +174,7 @@ export interface SpendRequest { link_transaction_id?: string; activity_url?: string; metadata?: Record; + expires_at?: number; created_at: string; updated_at: string; } diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index f8afe8d..850bfe2 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -184,6 +184,8 @@ Recommend the user approves with the [Link app](https://link.com/download). Show **Metadata:** Attach arbitrary string data with the repeatable `--metadata "key:value"` flag (CLI) or a `{ key: value }` object (MCP/agent). Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. Example: `--metadata "order_id:ord_123" --metadata "team:growth"`. +**Expiration:** Spend requests expire 12 hours after creation by default. Pass `--expires-at` on create with a unix timestamp (seconds) to override — must be 3 hours to 7 days in the future. Requires an allow-listed OAuth client; unlisted clients get a 400 error. + If the response has `status: "requires_action"`, read `status_details.requires_action.next_action` (`type`, `display_message`, `action_url`, `resolution`). Show `display_message` to the user; present `action_url` clearly if present. - If `resolution` is `auto_resume` (currently only `three_d_secure`), run the returned `_next.command` (poll `spend-request retrieve --interval 2 --max-attempts 300`) yourself — do not create a new spend request. The same request resumes to `approved`/`succeeded` once the user completes the bank's challenge. - Otherwise (`resolution` is `create_new_spend_request` or `create_new_spend_request_after_completion` — covers `ssn_verification`, `identity_verification`, `contact_support`, `select_payment_method`, `add_payment_method`, `update_payment_method`, `re_authorize`, `three_d_secure_retry`), have the user complete the indicated action, then create a **new** spend request — the old one will expire on its own. @@ -325,7 +327,7 @@ report `blocked`. Do not reuse the LPT at a different checkout surface. |-------|-------| | Max amount per spend request | $500 (50,000 cents) | | Approval window | 10 minutes — user must approve within 10 min of `spend-request request-approval` | -| Card / SPT validity (`valid_until`) | 12 hours from spend request creation | +| Card / SPT validity (`valid_until`) | 12 hours from spend request creation by default; `--expires-at` on create can extend up to 7 days (allow-listed clients only) | | Daily spend per account | $500 | | Monthly spend per account (30 days) | $20,000 | | Concurrent active requests (created + approved) | 30 | From 978ec4cec39a4599ed7cee556ce66d6e4fc2145a Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 19 Aug 2026 17:08:02 -0400 Subject: [PATCH 2/4] Hide --expires-at from docs and CLI schema output Most OAuth clients aren't allow-listed for the server-side flag; leaving it documented in SKILL.md/README.md/schema descriptions would prompt general agents to try it and hit a 400. The flag stays functional (same as the existing `approve` field) but drops its description so it no longer shows up meaningfully in --schema/--llms-full. Co-Authored-By: Claude Sonnet 5 Committed-By-Agent: claude --- CLAUDE.md | 2 +- README.md | 11 +---------- packages/cli/src/commands/spend-request/schema.ts | 8 +------- skills/create-payment-credential/SKILL.md | 4 +--- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e6a8fd..899dbb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ Key input field notes: - `--approval-detail` — optional JSON object (MCP/agent) or JSON string (CLI) with approval details for delegated flows. Required fields: `approved_at` (unix timestamp int), `approval_method` (`click`|`programmatic`|`voice`), `app_name`, `external_user_id`. Optional: `ip_address`, `user_agent`, `device_type` (`mobile`|`web`), `agent_log_id`, `external_user_name`, `external_session_id`, `authentication_method` (`biometric_face`|`biometric_fingerprint`|`passkey`). Sent as `approval_details` in the API request body. - `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (ISO date string — when the card expires/stops working) - `--output-file ` on `retrieve` or `create` writes full card credentials to a local file (0600 permissions) and redacts card data in stdout. `--force` allows overwriting an existing file. -- `--expires-at ` (create only) overrides the default 12-hour spend request expiration; must be 3 hours to 7 days in the future. Requires an allow-listed OAuth client — the server 400s with `"expires_at is not supported for this client"` otherwise. Not supported on `update`. The spend request response includes `expires_at` (epoch seconds) once set. +- `create` also accepts an undocumented `--expires-at ` to override the default 12-hour spend request expiration (3 hours to 7 days in the future). It's deliberately excluded from `--schema`/`--llms-full` output and from README/SKILL.md: it's gated to an allow-list of OAuth clients server-side, and most callers get a 400 (`"expires_at is not supported for this client"`) if they try it — don't document or suggest it to general agents. ### mpp pay diff --git a/README.md b/README.md index e7808f1..51b21b2 100644 --- a/README.md +++ b/README.md @@ -168,15 +168,6 @@ link-cli spend-request create ... \ In MCP/agent mode, pass `metadata` as a structured `{ key: value }` object. -#### Expiration - -By default, a spend request expires 12 hours after creation. Pass `--expires-at` with a unix timestamp (seconds) to request a longer or shorter expiration — must be between 3 hours and 7 days in the future. This requires an allow-listed OAuth client; unlisted clients get a 400 error. - -```bash -link-cli spend-request create ... \ - --expires-at 1720100000 -``` - #### Credential types By default, a spend request provisions a virtual card. For merchants that support the [Machine Payments Protocol](https://mpp.dev) (HTTP 402) and the Stripe payment method, instead pass `--credential-type "shared_payment_token"`. @@ -296,7 +287,7 @@ Link Pay Token requests require `execution_method=link_pay_token` and the DOM-derived `merchant_account_id`, and Link supplies their canonical merchant identity. -**Constraints:** `context` must be at least 100 characters; `amount` must not exceed 50000 (cents); `currency` must be a 3-letter ISO code. The user has 10 minutes from when approval is requested to approve. Approved credentials (card or SPT) are valid for 12 hours from spend request creation by default — pass `--expires-at` on create to override (3 hours to 7 days; requires an allow-listed OAuth client). +**Constraints:** `context` must be at least 100 characters; `amount` must not exceed 50000 (cents); `currency` must be a 3-letter ISO code. The user has 10 minutes from when approval is requested to approve. Approved credentials (card or SPT) are valid for 12 hours from spend request creation. **Test mode:** Pass `--test` to create a testmode SpendRequest. A testmode SpendRequest will return test payment credentials (e.g test card `4000009990001984`) rather than a real payment credential. Testmode SpendRequests will not charge the underlying payment method of the SpendRequest. This is useful for development and integration testing without real payment methods. ```bash diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index a65614f..e8f04d6 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -98,13 +98,7 @@ export const createOptions = z.object({ .describe( 'Metadata key:value pair (repeatable). Attaches arbitrary string data to the spend request. Max 50 keys, key <= 40 chars, value <= 500 chars. Example: "order_id:ord_123"', ), - expiresAt: z.coerce - .number() - .int() - .optional() - .describe( - 'Unix timestamp (seconds) when the spend request should expire. Must be 3 hours to 7 days in the future. Omit for the default 12-hour expiration. Requires an allow-listed OAuth client — the server returns a 400 error otherwise.', - ), + expiresAt: z.coerce.number().int().optional().describe(''), }); export const listOptions = z.object({ diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 850bfe2..f8afe8d 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -184,8 +184,6 @@ Recommend the user approves with the [Link app](https://link.com/download). Show **Metadata:** Attach arbitrary string data with the repeatable `--metadata "key:value"` flag (CLI) or a `{ key: value }` object (MCP/agent). Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. Example: `--metadata "order_id:ord_123" --metadata "team:growth"`. -**Expiration:** Spend requests expire 12 hours after creation by default. Pass `--expires-at` on create with a unix timestamp (seconds) to override — must be 3 hours to 7 days in the future. Requires an allow-listed OAuth client; unlisted clients get a 400 error. - If the response has `status: "requires_action"`, read `status_details.requires_action.next_action` (`type`, `display_message`, `action_url`, `resolution`). Show `display_message` to the user; present `action_url` clearly if present. - If `resolution` is `auto_resume` (currently only `three_d_secure`), run the returned `_next.command` (poll `spend-request retrieve --interval 2 --max-attempts 300`) yourself — do not create a new spend request. The same request resumes to `approved`/`succeeded` once the user completes the bank's challenge. - Otherwise (`resolution` is `create_new_spend_request` or `create_new_spend_request_after_completion` — covers `ssn_verification`, `identity_verification`, `contact_support`, `select_payment_method`, `add_payment_method`, `update_payment_method`, `re_authorize`, `three_d_secure_retry`), have the user complete the indicated action, then create a **new** spend request — the old one will expire on its own. @@ -327,7 +325,7 @@ report `blocked`. Do not reuse the LPT at a different checkout surface. |-------|-------| | Max amount per spend request | $500 (50,000 cents) | | Approval window | 10 minutes — user must approve within 10 min of `spend-request request-approval` | -| Card / SPT validity (`valid_until`) | 12 hours from spend request creation by default; `--expires-at` on create can extend up to 7 days (allow-listed clients only) | +| Card / SPT validity (`valid_until`) | 12 hours from spend request creation | | Daily spend per account | $500 | | Monthly spend per account (30 days) | $20,000 | | Concurrent active requests (created + approved) | 30 | From 1b6021f9cc434ea5cd08674c796cb2eb5b01cabf Mon Sep 17 00:00:00 2001 From: System Administrator Date: Thu, 20 Aug 2026 10:05:10 -0400 Subject: [PATCH 3/4] Note the unit for --expires-at in its schema description Bare field with no description gave zero signal, but agents seeing an undocumented integer field could just as easily guess wrong (e.g. milliseconds). Clarifying the unit alone doesn't explain the gating or bounds, so it stays unlikely to be tried speculatively. Co-Authored-By: Claude Sonnet 5 Committed-By-Agent: claude --- packages/cli/src/commands/spend-request/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index e8f04d6..73e62d2 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -98,7 +98,7 @@ export const createOptions = z.object({ .describe( 'Metadata key:value pair (repeatable). Attaches arbitrary string data to the spend request. Max 50 keys, key <= 40 chars, value <= 500 chars. Example: "order_id:ord_123"', ), - expiresAt: z.coerce.number().int().optional().describe(''), + expiresAt: z.coerce.number().int().optional().describe('Unix timestamp (seconds).'), }); export const listOptions = z.object({ From fcd73c68ff344056cae8adbbeaa85a34ad56dcf2 Mon Sep 17 00:00:00 2001 From: System Administrator Date: Thu, 20 Aug 2026 10:10:03 -0400 Subject: [PATCH 4/4] Fix biome formatting on expiresAt schema field CI was failing pnpm biome check on the line-length wrap for the one-line describe() call added in 1b6021f. Co-Authored-By: Claude Sonnet 5 Committed-By-Agent: claude --- packages/cli/src/commands/spend-request/schema.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index 73e62d2..d050516 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -98,7 +98,11 @@ export const createOptions = z.object({ .describe( 'Metadata key:value pair (repeatable). Attaches arbitrary string data to the spend request. Max 50 keys, key <= 40 chars, value <= 500 chars. Example: "order_id:ord_123"', ), - expiresAt: z.coerce.number().int().optional().describe('Unix timestamp (seconds).'), + expiresAt: z.coerce + .number() + .int() + .optional() + .describe('Unix timestamp (seconds).'), }); export const listOptions = z.object({