Add payments to your agent
Use a Link SDK to let your agent request payment credentials from a customer’s Link wallet. Your application connects the customer’s Link account, creates a spend request describing the proposed purchase, asks the customer to approve it, and then uses the scoped credential to complete checkout.
This hosted integration is available for US and Canadian customers. Your business, agent, and the sellers your customers buy from can be outside these countries.
Link’s API lives at api.link.com and authorizes against login.link.com, separate from api.stripe.com. You authenticate with an OAuth access token that represents one customer. Stripe secret keys and restricted API keys don’t work, and the Stripe server-side SDKs don’t cover these endpoints.
Before you begin
This guide covers a hosted application. For an agent running locally, use the Link CLI quickstart.
Hosted authorization requirements
Prepare the following before connecting a customer’s Link account:
- A supported runtime: Node.js 20 or newer for TypeScript, Go 1.23 or newer, or Python 3.11 or newer. TypeScript requires an ESM project.
- A registered OAuth client, including a
client_id,client_secret, and exact redirect URIs. Follow OAuth client registration if you don’t have these yet. - The Stripe publishable key from the API keys in your Dashboard.
- A server-side callback at a registered redirect URI, a session store for authorization state, and secure storage for access and refresh tokens.
For the payment examples in this guide, you also need a customer with a US or Canadian Link account and an eligible payment method.
Integrate Link payments
The code blocks are integration excerpts. Your application supplies the credential storage, customer messaging, saved run state, and checkout execution described in each step.
Install the SDK
Install the SDK for your language.
npm install @stripe/link-sdkgo get github.com/stripe/link-cli/packages/sdk-gopip install stripe-link-sdkThe TypeScript package exports Link as both a default and a named export. Each SDK provides an authenticated client for the steps that follow.
Register a hosted OAuth client
Skip this step if you already have a registered client. A hosted agent uses the OAuth 2.0 authorization-code flow with a confidential client. Create a Stripe account, then apply for an OAuth client through the registration form. Provide your app name, a description, and the exact redirect URIs you need. Matching is exact, so register every URI. Stripe sends you a client_id and client_secret.
Client registration is part of this hosted integration. Customers using Link CLI’s local authorization flow don’t need to register a confidential OAuth client.
Redirect the customer to Link
Generate a Proof Key for Code Exchange (PKCE) verifier and challenge, store the verifier and a random state value in the customer’s server-side session, then redirect them to:
https://login.link.com/auth?key=pk_live_YOUR_PUBLISHABLE_KEY&client_id=YOUR_CLIENT_ID&redirect_uri=https://yourapp.com/callback&response_type=code&scope=payment_methods.agentic%20userinfo:read&state=RANDOM_STATE_VALUE&code_challenge=YOUR_PKCE_CODE_CHALLENGE&code_challenge_method=S256URL-encode every parameter value. The parameters are:
| Parameter | Value |
|---|---|
key | Your Stripe publishable key. |
client_id | The OAuth client ID from Stripe. |
redirect_uri | An exact match for a URI registered with your OAuth client. |
response_type | code |
scope | Space-separated (%20) or comma-separated scopes. See the following table. |
state | A random value that you verify on callback to prevent cross-site request forgery (CSRF). |
code_challenge | Optional for confidential clients, but recommended. BASE64URL(SHA256(code_verifier)), using a random, URL-safe verifier 43–128 characters long. |
code_challenge_method | S256. Required if you send code_challenge; any other value is rejected. |
Request the scopes your feature needs:
| Scope | Use it to |
|---|---|
payment_methods.agentic | Create spend requests and retrieve credentials. Required for every payment integration. |
userinfo:read | Read the customer’s name and email to prefill forms and check eligibility. |
financial_insights.sources.read, financial_insights.balances.read, financial_insights.transactions.read | Read financial data. These pair with the authorization_details parameter described in Financial insights. |
After the customer authenticates and approves access, Link redirects to your callback with code and state. Verify state before doing anything else. The authorization code is single-use and must be exchanged within 10 minutes. If the customer declines, or isn’t eligible for a scope, Link redirects with an error instead of a code.
Exchange the code for tokens
Exchange the code from your server. Send your publishable key in the Authorization header, and include the original PKCE verifier:
curl -X POST https://login.link.com/auth/token \
-H "Authorization: Bearer pk_live_YOUR_PUBLISHABLE_KEY" \
-d "grant_type=authorization_code" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "redirect_uri=https://yourapp.com/callback" \
-d "code=AUTHORIZATION_CODE" \
-d "code_verifier=YOUR_PKCE_CODE_VERIFIER"Link returns both tokens:
{
"access_token": "liwltoken_...",
"refresh_token": "liwlrefresh_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "payment_methods.agentic userinfo:read"
}The response echoes the scope (and authorization_details) the customer actually granted. A customer can approve only a subset, so read it instead of assuming you received everything you asked for.
Store both tokens securely on your server. Access tokens last one hour. Refresh tokens last one year and rotate every time they’re used. Atomically replace the stored refresh token with the new one from each refresh response.
Create the SDK client
The SDK accepts an OAuth access token, but it doesn’t perform login, persist credentials, or own refresh tokens. Provide a function that returns a current access token. The SDK calls it before every request and, as a fallback after a 401, calls it once more with a forced-refresh signal.
In these examples, credentialManager (credential_manager in Python) is your application’s token store, not a Link SDK export. It returns the customer’s current token and refreshes it when requested.
import Link from '@stripe/link-sdk'
const link = new Link({
getAccessToken: async ({ forceRefresh } = {}) =>
credentialManager.getLinkAccessToken({ forceRefresh }),
})client, err := link.NewClient(link.Options{
GetAccessToken: func(
ctx context.Context,
options link.GetAccessTokenOptions,
) (string, error) {
return credentialManager.LinkAccessToken(ctx, options.ForceRefresh)
},
})
if err != nil {
return err
}from link import Client, GetAccessTokenOptions
def get_token(options: GetAccessTokenOptions) -> str:
return credential_manager.link_access_token(force_refresh=options.force_refresh)
client = Client(get_access_token=get_token)Your credential manager should track token expiry, coalesce concurrent refreshes, persist rotated refresh tokens safely, and keep all tokens out of agent context, logs, and client-side code.
Choose a payment method
List the payment methods in the connected Link wallet and select the default eligible method:
const methods = await link.paymentMethods.list()
const eligibleMethods = methods.filter(
(method) => method.capabilities?.agentic_payments?.eligible !== false,
)
const paymentMethod =
eligibleMethods.find((method) => method.is_default) ?? eligibleMethods[0]
if (!paymentMethod) {
throw new Error('The user needs to add an eligible Link payment method.')
}methods, err := client.PaymentMethods.List(ctx)
if err != nil {
return err
}
var paymentMethod *link.PaymentMethod
for index := range methods {
capability, exists := methods[index].Capabilities["agentic_payments"]
if exists && !capability.Eligible {
continue
}
if paymentMethod == nil || methods[index].IsDefault {
paymentMethod = &methods[index]
}
}
if paymentMethod == nil {
return errors.New("the user needs to add an eligible Link payment method")
}methods = client.payment_methods.list()
eligible_methods = [
method
for method in methods
if (method.capabilities or {})
.get("agentic_payments", {})
.get("eligible")
is not False
]
payment_method = next(
(method for method in eligible_methods if method.is_default),
eligible_methods[0] if eligible_methods else None,
)
if payment_method is None:
raise RuntimeError("The user needs to add an eligible Link payment method.")Ineligible methods include a capabilities.agentic_payments object with eligible: false and ineligibility_reasons.
Link CLI calls this selector payment_method_id, while the SDKs send the selected method ID as payment_details.
Create a spend request
Describe what the agent is buying and why. Amounts use the currency’s minor unit, such as cents for USD, and context must contain at least 100 characters.
const spendRequest = await link.spendRequests.create({
payment_details: paymentMethod.id,
credential_type: 'card',
amount: 2599,
currency: 'usd',
merchant_name: 'Acme',
merchant_url: 'https://acme.example',
context:
'The user asked the agent to buy the selected item from Acme for $25.99, including the displayed shipping cost.',
})amount := int64(2599)
currency := "usd"
credentialType := link.CredentialTypeCard
merchantName := "Acme"
merchantURL := "https://acme.example"
spendRequest, err := client.SpendRequests.Create(ctx, link.CreateSpendRequestParams{
PaymentDetails: &paymentMethod.ID,
CredentialType: &credentialType,
Amount: &amount,
Currency: ¤cy,
MerchantName: &merchantName,
MerchantURL: &merchantURL,
Context: "The user asked the agent to buy the selected item from Acme for $25.99, including the displayed shipping cost.",
})
if err != nil {
return err
}spend_request = client.spend_requests.create(
payment_details=payment_method.id,
credential_type="card",
amount=2599,
currency="usd",
merchant_name="Acme",
merchant_url="https://acme.example",
context=(
"The user asked the agent to buy the selected item from Acme for $25.99, "
"including the displayed shipping cost."
),
)Pass an idempotency_key (IdempotencyKey in Go) with a stable, non-sensitive value for the purchase so that a retried create returns the original request. See Error handling.
Ask for approval
Request approval, send the returned URL to the customer, and save the spend request ID so a later agent run can resume the purchase. The sendToUser and saveRunState helpers (and their Python equivalents) represent your application’s messaging and persistent storage.
const approval = await link.spendRequests.requestApproval(spendRequest.id)
const approvalDeadline = Date.now() + 30 * 60 * 1000
await sendToUser(`Approve this purchase: ${approval.approval_url}`)
await saveRunState({ spendRequestId: spendRequest.id, approvalDeadline })approval, err := client.SpendRequests.RequestApproval(ctx, spendRequest.ID)
if err != nil {
return err
}
sendToUser(fmt.Sprintf("Approve this purchase: %s", approval.ApprovalURL))
saveRunState(spendRequest.ID, time.Now().Add(30*time.Minute))approval = client.spend_requests.request_approval(spend_request.id)
approval_deadline = time.time() + 30 * 60
send_to_user(f"Approve this purchase: {approval.approval_url}")
save_run_state(spend_request.id, approval_deadline)The customer has 30 minutes to approve the request.
Return the customer to your app
Optionally, send the customer back to your application after they finish an approval or required action. Supply a completion callback when you create the request. This is separate from the OAuth redirect_uri that you registered for authorization.
| Input | CLI flag | SDK field | Requirements |
|---|---|---|---|
| Callback URL | --completion-callback-url | completion_callback_url (TypeScript, Python), CompletionCallbackURL (Go) | A valid URL of at most 2,048 characters. In live mode, the API requires HTTPS and enforces URL security rules. |
| Nonce | --nonce | nonce (TypeScript, Python), Nonce (Go) | Nonblank, at most 512 JavaScript/UTF-16 code units. |
Both inputs are create-only. Supply both or neither. Link CLI and the SDK agent-tool schemas validate these constraints locally. The language SDKs pass the values through, and the API performs the server-side validation, so don’t rely on identical client-side errors across SDKs.
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
await saveRunState({ nonce })
const spendRequest = await link.spendRequests.create({
// ...the purchase fields from the previous example
completion_callback_url: 'https://agent.example.com/link/callback',
nonce,
})callbackURL := "https://agent.example.com/link/callback"
nonce := newOpaqueNonce() // At least 128 bits from a cryptographically secure source.
saveRunState(nonce)
spendRequest, err := client.SpendRequests.Create(ctx, link.CreateSpendRequestParams{
// ...the purchase fields from the previous example
CompletionCallbackURL: &callbackURL,
Nonce: &nonce,
})nonce = secrets.token_urlsafe(32)
save_run_state(nonce=nonce)
spend_request = client.spend_requests.create(
# ...the purchase fields from the previous example
completion_callback_url="https://agent.example.com/link/callback",
nonce=nonce,
)The CLI equivalent adds --completion-callback-url "https://agent.example.com/link/callback" --nonce "$NONCE" to spend-request create.
Follow these rules:
- Generate an opaque, cryptographically unpredictable nonce. Save it unchanged and verify an exact match when the callback arrives. Don’t trim or normalize it.
- Use the returned
approval_urlandstatus_details.requires_action.next_action.action_urlas-is. Don’t append callback parameters yourself, because not every action URL carries the callback. - A callback arriving doesn’t mean the customer approved or the payment succeeded. Retrieve the request and inspect its current status before you continue. The polling and required-action rules still apply.
- The completion callback isn’t the OAuth redirect and isn’t a webhook registration. Keep your existing polling or retrieval logic as the source of truth.
Wait for the approval result
The SDKs don’t poll automatically. Follow the approval polling rules, including how to show required actions and recover from a timeout.
Use the polling example for your language with the request ID and deadline you saved in the previous step:
- TypeScript.
- Go.
- Python.
Save the returned spend request as result for the next step. Load the request ID and deadline from your application’s saved state when resuming a purchase.
Retrieve and use the credential
After the request is approved, retrieve again with the card expansion. This keeps the full credential out of routine polling responses. The checkoutWorker and notifyUser helpers (and their Python equivalents) represent your application’s checkout executor and customer messaging. Keep the checkout executor outside model context.
if (result.status !== 'approved') {
await notifyUser(`Purchase not approved: ${result.status}`)
return
}
const approved = await link.spendRequests.retrieve(result.id, {
include: ['card'],
})
if (!approved?.card) {
throw new Error('Approved spend request did not include a card.')
}
await checkoutWorker.complete(approved.card)if result.Status != link.SpendRequestStatusApproved {
notifyUser(fmt.Sprintf("Purchase not approved: %s", result.Status))
return nil
}
approved, err := client.SpendRequests.Retrieve(
ctx,
result.ID,
&link.RetrieveSpendRequestParams{Include: []string{"card"}},
)
if err != nil {
return err
}
if approved == nil || approved.Card == nil {
return errors.New("approved spend request did not include a card")
}
return checkoutWorker.Complete(ctx, approved.Card)if result.status != "approved":
notify_user(f"Purchase not approved: {result.status}")
return
approved = client.spend_requests.retrieve(result.id, include=["card"])
if approved is None or approved.card is None:
raise RuntimeError("Approved spend request did not include a card.")
checkout_worker.complete(approved.card)The returned card contains id, number, brand, exp_month, exp_year, and optional cvc, billing_address, and valid_until. Keep returned card credentials out of model context, logs, and customer-visible messages.
Refresh and revoke access
Refresh before the access token expires instead of waiting for a 401:
curl -X POST https://login.link.com/auth/token \
-H "Authorization: Bearer pk_live_YOUR_PUBLISHABLE_KEY" \
-d "grant_type=refresh_token" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "refresh_token=CURRENT_REFRESH_TOKEN"When a customer disconnects your agent, revoke their refresh token. Revoking it ends the grant and invalidates the access tokens issued from it:
curl -X POST https://login.link.com/auth/revoke \
-H "Authorization: Bearer pk_live_YOUR_PUBLISHABLE_KEY" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "token=TOKEN_TO_REVOKE" \
-d "token_type_hint=refresh_token"Know the limits
Review the limits table for purchase amounts, request rates, approval windows, and credential validity. Use Error handling to plan recovery when a call fails.
Next steps
Extend your integration with another payment option or financial data:
Use a Shared Payment Token for a machine payment.
Complete a compatible Stripe-hosted checkout.
Request access to customer-shared transactions and balances.