From 3377d1cbf652d1443d5087fef59aa251e256282c Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Mon, 28 Sep 2026 10:32:05 -0400 Subject: [PATCH 1/2] expose device_id in approval details Committed-By-Agent: codex Co-authored-by: codex --- CLAUDE.md | 2 +- README.md | 2 ++ .../cli/src/commands/spend-request/schema.ts | 2 +- packages/sdk-go/client_test.go | 22 +++++++++++++++++++ packages/sdk-go/types.go | 1 + packages/sdk-python/src/link/models.py | 1 + packages/sdk-python/src/link/params.py | 1 + packages/sdk-python/tests/test_resources.py | 1 + packages/sdk-python/tests/test_typing.py | 3 ++- .../resources/__tests__/spend-request.test.ts | 20 +++++++++++++++++ packages/sdk/src/types/index.ts | 1 + skills/create-payment-credential/SKILL.md | 2 +- 12 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd51ff00..c520a02d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Key input field notes: - `retrieve --interval ` waits for the initial status to change. Polling starts only for `created`, `pending_approval`, or `requires_action` with `auto_resume`; all other statuses (including `submitted` and unknown future values) return immediately. Any status change returns, even to another waiting state. JSON and interactive retrieve share `shouldPollSpendRequest`. If `--timeout` or `--max-attempts` is reached without a change, JSON polling exits non-zero with `POLLING_TIMEOUT`. - Both `create` and `retrieve` (including approval polling) can return `status: 'requires_action'` with `status_details.requires_action.next_action` (`type`, `display_message`, `action_url`, `resolution`). With `resolution: 'auto_resume'` (currently only `next_action.type: 'three_d_secure'`), retrieve the same request again to wait for its status to change; interactive create resumes automatically. Any other resolution stops polling immediately; the caller must have the user complete the action, then create a new spend request. - `cancel ` cancels a spend request. Can cancel from `created`, `pending_approval`, or `approved` states. Returns the spend request with `status: "canceled"`. -- `--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. +- `--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`, `device_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. - `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. diff --git a/README.md b/README.md index 6409739e..a3ac3fa2 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,8 @@ link-cli spend-request create \ The `--request-approval` flag triggers a push notification to the user for approval. Interactive mode polls until the request leaves the approval waiting states. Agent mode returns a `spend-request retrieve` command to wait for a status change, including a transition to `submitted`. +For delegated or pre-approved flows, `--approval-detail` accepts a JSON object (MCP/agent) or JSON string (CLI). In addition to the required approval fields, it supports optional evidence including `ip_address`, `user_agent`, `device_type`, `agent_log_id`, `external_user_name`, `external_session_id`, `device_id`, and `authentication_method`. + Easily approve requests with the [Link app](https://link.com/download). ### Execute payment diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index cd8e1220..f2cf12fc 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -95,7 +95,7 @@ export const createOptions = z.object({ .union([z.string(), z.record(z.string(), z.unknown())]) .optional() .describe( - 'Approval details object (MCP/agent: pass as object; CLI: pass as JSON string). Required fields: approved_at (unix timestamp), 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).', + 'Approval details object (MCP/agent: pass as object; CLI: pass as JSON string). Required fields: approved_at (unix timestamp), 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, device_id, authentication_method (biometric_face|biometric_fingerprint|passkey).', ), metadata: z .array(z.union([z.string(), z.record(z.string(), z.string())])) diff --git a/packages/sdk-go/client_test.go b/packages/sdk-go/client_test.go index 79cd495c..ac1506bf 100644 --- a/packages/sdk-go/client_test.go +++ b/packages/sdk-go/client_test.go @@ -261,6 +261,28 @@ func TestCreateSpendRequestIdempotencyKeyEncoding(t *testing.T) { } } +func TestCreateSpendRequestApprovalDetailsDeviceIDEncoding(t *testing.T) { + deviceID := "device_123" + data, err := json.Marshal(CreateSpendRequestParams{ + Context: "A sufficiently detailed context for testing approval detail encoding.", + ApprovalDetails: &ApprovalDetail{ + ApprovedAt: 123, + ApprovalMethod: ApprovalMethodProgrammatic, + AppName: "Test app", + ExternalUserID: "user_123", + DeviceID: &deviceID, + }, + }) + assertNoError(t, err) + var fields map[string]json.RawMessage + assertNoError(t, json.Unmarshal(data, &fields)) + var approvalDetails map[string]any + assertNoError(t, json.Unmarshal(fields["approval_details"], &approvalDetails)) + if approvalDetails["device_id"] != deviceID { + t.Fatalf("got device_id %#v, want %q", approvalDetails["device_id"], deviceID) + } +} + func TestCreateSpendRequestIncompleteIdempotentRequestReturnsAPIError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusConflict) diff --git a/packages/sdk-go/types.go b/packages/sdk-go/types.go index 45004672..aa013312 100644 --- a/packages/sdk-go/types.go +++ b/packages/sdk-go/types.go @@ -170,6 +170,7 @@ type ApprovalDetail struct { AgentLogID *string `json:"agent_log_id,omitempty"` ExternalUserName *string `json:"external_user_name,omitempty"` ExternalSessionID *string `json:"external_session_id,omitempty"` + DeviceID *string `json:"device_id,omitempty"` AuthenticationMethod *AuthenticationMethod `json:"authentication_method,omitempty"` } diff --git a/packages/sdk-python/src/link/models.py b/packages/sdk-python/src/link/models.py index 9bcdf961..ab76d4e4 100644 --- a/packages/sdk-python/src/link/models.py +++ b/packages/sdk-python/src/link/models.py @@ -92,6 +92,7 @@ class ApprovalDetail(LinkModel): agent_log_id: str | None = None external_user_name: str | None = None external_session_id: str | None = None + device_id: str | None = None authentication_method: AuthenticationMethod | str | None = None diff --git a/packages/sdk-python/src/link/params.py b/packages/sdk-python/src/link/params.py index f177fe86..5c03a0b9 100644 --- a/packages/sdk-python/src/link/params.py +++ b/packages/sdk-python/src/link/params.py @@ -54,6 +54,7 @@ class ApprovalDetailParams(TypedDict, total=False): agent_log_id: str | None external_user_name: str | None external_session_id: str | None + device_id: str | None authentication_method: AuthenticationMethod | None diff --git a/packages/sdk-python/tests/test_resources.py b/packages/sdk-python/tests/test_resources.py index 25cac694..60c8a5b7 100644 --- a/packages/sdk-python/tests/test_resources.py +++ b/packages/sdk-python/tests/test_resources.py @@ -243,6 +243,7 @@ async def test_create_all_fields_and_nested_omission(api: API) -> None: "agent_log_id": "log", "external_user_name": "name", "external_session_id": "session", + "device_id": "device", "authentication_method": "passkey", }, } diff --git a/packages/sdk-python/tests/test_typing.py b/packages/sdk-python/tests/test_typing.py index b8de40d6..026f2291 100644 --- a/packages/sdk-python/tests/test_typing.py +++ b/packages/sdk-python/tests/test_typing.py @@ -38,7 +38,8 @@ def valid(client: Client) -> None: client.spend_requests.create( context="Purchase", line_items=[{"name": "Item", "totals": []}], approval_details={"approved_at": 1, "approval_method": "voice", - "app_name": "app", "external_user_id": "user"}, + "app_name": "app", "external_user_id": "user", + "device_id": "device"}, ) async def valid_async(client: AsyncClient) -> None: diff --git a/packages/sdk/src/resources/__tests__/spend-request.test.ts b/packages/sdk/src/resources/__tests__/spend-request.test.ts index 297e6f59..f31d464f 100644 --- a/packages/sdk/src/resources/__tests__/spend-request.test.ts +++ b/packages/sdk/src/resources/__tests__/spend-request.test.ts @@ -90,6 +90,26 @@ describe('SpendRequestResource', () => { expect(result).toEqual(spendRequestResponse); }); + it('includes device_id in approval details', async () => { + mockFetchResponse(200, spendRequestResponse); + + await repo.create({ + ...validParams, + approval_details: { + approved_at: 123, + approval_method: 'programmatic', + app_name: 'Test app', + external_user_id: 'user_123', + device_id: 'device_123', + }, + }); + + const [, opts] = mockFetch.mock.calls[0]!; + expect(JSON.parse(opts.body).approval_details.device_id).toBe( + 'device_123', + ); + }); + it('accepts omitted optional fields and a null shared payment token', async () => { mockFetchResponse(200, sparseSpendRequestResponse); diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index cf396358..0992d749 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -101,6 +101,7 @@ export interface ApprovalDetail { agent_log_id?: string; external_user_name?: string; external_session_id?: string; + device_id?: string; authentication_method?: | 'biometric_face' | 'biometric_fingerprint' diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index b70be68d..766d616c 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -203,7 +203,7 @@ Recommend the user approves with the [Link app](https://link.com/download). Show **Test mode:** Add `--test` to create testmode credentials instead of real ones. Useful for development and integration testing. Link Pay Token does not support test mode. -**Approval details:** For delegated/pre-approved flows, pass `--approval-detail` as a JSON object (MCP/agent) or JSON string (CLI). Required fields: `approved_at` (unix timestamp), `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`). +**Approval details:** For delegated/pre-approved flows, pass `--approval-detail` as a JSON object (MCP/agent) or JSON string (CLI). Required fields: `approved_at` (unix timestamp), `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`, `device_id`, `authentication_method` (`biometric_face`|`biometric_fingerprint`|`passkey`). **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"`. From 7904a8008cbe6948982f07cf21581173ac1969a8 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 30 Sep 2026 13:06:19 -0400 Subject: [PATCH 2/2] Remove approval detail documentation Committed-By-Agent: codex Co-authored-by: codex --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index a3ac3fa2..6409739e 100644 --- a/README.md +++ b/README.md @@ -227,8 +227,6 @@ link-cli spend-request create \ The `--request-approval` flag triggers a push notification to the user for approval. Interactive mode polls until the request leaves the approval waiting states. Agent mode returns a `spend-request retrieve` command to wait for a status change, including a transition to `submitted`. -For delegated or pre-approved flows, `--approval-detail` accepts a JSON object (MCP/agent) or JSON string (CLI). In addition to the required approval fields, it supports optional evidence including `ip_address`, `user_agent`, `device_type`, `agent_log_id`, `external_user_name`, `external_session_id`, `device_id`, and `authentication_method`. - Easily approve requests with the [Link app](https://link.com/download). ### Execute payment