Skip to content
Logo

Go SDK

The Link Go SDK provides typed resources for Link APIs and accepts an access token from your application.

The SDK doesn’t perform login, persist credentials, or own refresh tokens. Authentication state and user-facing authorization flows belong to Link CLI or the application embedding the SDK.

Go SDK source

Install

Use Go 1.23 or newer. Add the SDK to your Go module:

go get github.com/stripe/link-cli/packages/sdk-go

Import the package as link:

import link "github.com/stripe/link-cli/packages/sdk-go"

Create a client

Pass an access token when creating the client:

client, err := link.NewClient(link.Options{
	AccessToken: os.Getenv("LINK_ACCESS_TOKEN"),
})
if err != nil {
	log.Fatal(err)
}
 
paymentMethods, err := client.PaymentMethods.List(context.Background())
if err != nil {
	log.Fatal(err)
}

A fixed token is appropriate for a short-lived job or when the caller replaces the client whenever credentials change.

Manage dynamic access tokens

When your application manages expiring credentials, provide GetAccessToken. The SDK calls the callback for every request. After a 401 response, it calls the callback once with ForceRefresh: true and retries with the returned token.

In this example, credentialManager is your application’s token store.

client, err := link.NewClient(link.Options{
	GetAccessToken: func(
		ctx context.Context,
		options link.GetAccessTokenOptions,
	) (string, error) {
		return credentialManager.LinkAccessToken(ctx, options.ForceRefresh)
	},
})

Your credential manager should coalesce concurrent refreshes. You must provide exactly one credential option: AccessToken or GetAccessToken.

Create a customer-approved purchase

The following excerpts assume an authenticated client that remains open for the purchase flow. Your application supplies messaging, persistent run state, and checkout execution. These examples create live requests. Use test mode while developing.

Amounts use the currency’s minor unit, such as cents for USD. Context must contain at least 100 characters and should tell the customer what the agent is buying and why.

paymentMethods, err := client.PaymentMethods.List(ctx)
if err != nil {
	return err
}
var paymentMethod *link.PaymentMethod
for index := range paymentMethods {
	capability, exists := paymentMethods[index].Capabilities["agentic_payments"]
	if exists && !capability.Eligible {
		continue
	}
	if paymentMethod == nil || paymentMethods[index].IsDefault {
		paymentMethod = &paymentMethods[index]
	}
}
if paymentMethod == nil {
	return errors.New("the user needs to add an eligible Link payment method")
}
 
amount := int64(2599)
currency := "usd"
credentialType := link.CredentialTypeCard
merchantName := "Acme"
merchantURL := "https://acme.example"
purchaseID := "order-1234" // Stable and non-sensitive; store it with your order.
 
spendRequest, err := client.SpendRequests.Create(
	ctx,
	link.CreateSpendRequestParams{
		IdempotencyKey: &purchaseID,
		PaymentDetails: &paymentMethod.ID,
		CredentialType: &credentialType,
		Amount:         &amount,
		Currency:       &currency,
		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
}
 
approval, err := client.SpendRequests.RequestApproval(ctx, spendRequest.ID)
if err != nil {
	return err
}
 
fmt.Printf("Ask the user to approve this purchase: %s\n", approval.ApprovalURL)
saveRunState(spendRequest.ID, time.Now().Add(30*time.Minute))

PaymentDetails is the selected payment method ID. Ineligible methods include a Capabilities["agentic_payments"] entry with Eligible: false and IneligibilityReasons.

Persist the spend request ID and deadline so a later agent run can continue after the customer acts.

Add recurring terms or a completion callback

Both inputs are create-only and optional. For a $20 monthly spending limit, set Amount to 2000, Currency to "usd", and add Recurring. Amount is the limit for each period:

Recurring: &link.SpendRequestRecurringParams{
	Interval: link.RecurringIntervalMonth, // IntervalCount (*int64) is optional and defaults to 1.
},

Use RecurringIntervalDay, RecurringIntervalWeek, RecurringIntervalMonth, or RecurringIntervalYear. Update can’t change recurring terms, and Cancel revokes the credential without canceling the merchant’s subscription. See Recurring payments.

To return the customer to your app after approval, set the CompletionCallbackURL and Nonce string pointers together:

CompletionCallbackURL: &callbackURL,
Nonce:                 &nonce, // Opaque, unpredictable, and saved unchanged for exact comparison.

The API validates these values. See Return the customer to your app.

Handle the result

This excerpt uses waitForApproval from Complete example. Load state from your persistent run store. Your application supplies notifyUser for messaging and checkoutWorker.Complete for checkout execution.

The SDK doesn’t poll or register a webhook. Poll created and pending_approval yourself until the saved 30-minute approval deadline. Always show a required action’s message and any ActionURL to the customer. Continue the same request only for NextActionResolutionAutoResume.

result, err := waitForApproval(
	ctx,
	client,
	state.SpendRequestID,
	state.ApprovalDeadline,
)
if err != nil {
	return err
}
 
if result.Status == link.SpendRequestStatusApproved {
	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)
}
 
notifyUser(fmt.Sprintf("Purchase not approved: %s", result.Status))

The second retrieve deliberately requests card only after approval. Keep returned card credentials out of model context, logs, and customer-visible messages. See Virtual card for the complete object structure.

Run the whole flow

Save this program as main.go in the module where you installed the SDK, supply LINK_ACCESS_TOKEN through your environment, and run go run .. It selects an eligible payment method, creates a request, asks for approval, polls, and retrieves the card. The included checkout function is a placeholder that makes no purchase. Replace it with your own payment executor when you’re ready to complete checkout.

This example creates a live request and retrieves a live credential. Use test mode while developing.

package main
 
import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
 
	link "github.com/stripe/link-cli/packages/sdk-go"
)
 
func main() {
	if err := run(context.Background()); err != nil {
		log.Fatal(err)
	}
}
 
func run(ctx context.Context) error {
	client, err := link.NewClient(link.Options{
		AccessToken: os.Getenv("LINK_ACCESS_TOKEN"),
		HTTPClient:  &http.Client{Timeout: 30 * time.Second},
	})
	if err != nil {
		return err
	}
 
	paymentMethod, err := pickPaymentMethod(ctx, client)
	if err != nil {
		return err
	}
 
	purchaseID := "order-1234" // Stable and non-sensitive; store it with your order.
	amount := int64(2599)
	currency := "usd"
	credentialType := link.CredentialTypeCard
	merchantName := "Acme"
	merchantURL := "https://acme.example"
 
	spendRequest, err := client.SpendRequests.Create(ctx, link.CreateSpendRequestParams{
		IdempotencyKey: &purchaseID,
		PaymentDetails: &paymentMethod.ID,
		CredentialType: &credentialType,
		Amount:         &amount,
		Currency:       &currency,
		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 {
		duplicate := link.GetDuplicateSpendRequest(err)
		if duplicate == nil {
			return err
		}
		// Continue with the existing request instead of creating another.
		spendRequest = duplicate
	}
 
	approval, err := client.SpendRequests.RequestApproval(ctx, spendRequest.ID)
	if err != nil {
		return err
	}
	fmt.Printf("Ask the user to approve this purchase: %s\n", approval.ApprovalURL)
 
	// In production, persist the ID and deadline so a later run can continue.
	deadline := time.Now().Add(30 * time.Minute)
	result, err := waitForApproval(ctx, client, spendRequest.ID, deadline)
	if err != nil {
		return err
	}
 
	switch result.Status {
	case link.SpendRequestStatusApproved:
		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 checkout(ctx, approved.Card)
	case link.SpendRequestStatusSubmitted, link.SpendRequestStatusSucceeded:
		fmt.Println("The payment is on its way.")
		return nil
	case link.SpendRequestStatusRequiresAction:
		return errors.New("the user must finish the requested action; create a new request afterward")
	default:
		return fmt.Errorf("purchase not approved: %s", result.Status)
	}
}
 
func pickPaymentMethod(ctx context.Context, client *link.Client) (*link.PaymentMethod, error) {
	methods, err := client.PaymentMethods.List(ctx)
	if err != nil {
		return nil, err
	}
 
	var chosen *link.PaymentMethod
	for index := range methods {
		capability, exists := methods[index].Capabilities["agentic_payments"]
		if exists && !capability.Eligible {
			continue
		}
		if chosen == nil || methods[index].IsDefault {
			chosen = &methods[index]
		}
	}
	if chosen == nil {
		return nil, errors.New("the user needs to add an eligible Link payment method")
	}
	return chosen, nil
}
 
func surfaceActionToUser(message string, actionURL *string) {
	if actionURL != nil {
		fmt.Printf("%s %s\n", message, *actionURL)
		return
	}
	fmt.Println(message)
}
 
// checkout hands the card to your payment executor. Never log or print the card.
func checkout(ctx context.Context, card *link.Card) error {
	_ = ctx
	_ = card
	return nil
}
 
func waitForApproval(
	ctx context.Context,
	client *link.Client,
	spendRequestID string,
	deadline time.Time,
) (*link.SpendRequest, error) {
	shownAction := ""
	for time.Now().Before(deadline) {
		request, err := client.SpendRequests.Retrieve(ctx, spendRequestID, nil)
		if err != nil {
			return nil, err
		}
		if request == nil {
			return nil, errors.New("spend request not found")
		}
 
		switch request.Status {
		case link.SpendRequestStatusCreated,
			link.SpendRequestStatusPendingApproval:
			// Keep polling below.
		case link.SpendRequestStatusRequiresAction:
			details := request.StatusDetails
			if details == nil || details.RequiresAction == nil {
				return nil, errors.New("spend request requires action without instructions")
			}
			action := details.RequiresAction.NextAction
 
			// Show the action once, not on every poll.
			if key := actionKey(action); key != shownAction {
				surfaceActionToUser(action.DisplayMessage, action.ActionURL)
				shownAction = key
			}
			if action.Resolution != link.NextActionResolutionAutoResume {
				return request, nil
			}
		default:
			return request, nil
		}
 
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(2 * time.Second):
		}
	}
 
	return nil, errors.New(
		"the approval window ended; retrieve this request once more before creating another",
	)
}
 
func actionKey(action link.NextAction) string {
	actionURL := ""
	if action.ActionURL != nil {
		actionURL = *action.ActionURL
	}
	var expiresAt int64
	if action.ExpiresAt != nil {
		expiresAt = *action.ExpiresAt
	}
	return fmt.Sprintf("%s|%d", actionURL, expiresAt)
}

Configure the client

Set an HTTP timeout and override transport or logging options as needed. Here, diagnosticsLogger is your application’s logger:

client, err := link.NewClient(link.Options{
	AccessToken: accessToken,
	HTTPClient: &http.Client{
		Timeout: 30 * time.Second,
	},
	DefaultHeaders: http.Header{
		"X-Agent-Version": []string{"acme-agent/1.0"},
	},
	Verbose:             true,
	Logger:              diagnosticsLogger,
	APIBaseURL:          "https://api.link.com",
	SpendRequestBaseURL: "https://api.link.com",
})

HTTPClient supports custom transports, proxies, tracing, and timeouts. When you don’t set it, the SDK uses http.DefaultClient, which has no timeout, so set one in production. Verbose logging includes request methods, URLs, and response status codes, but not authorization headers or request and response bodies.

Handle errors

The SDK returns typed configuration, transport, API, and response errors. Use errors.As to inspect them:

var apiError *link.LinkAPIError
if errors.As(err, &apiError) {
	log.Printf(
		"Link returned status=%d code=%s details=%v",
		apiError.Status,
		apiError.Code,
		apiError.Details,
	)
}

Every SDK-created error implements link.LinkError, and wrapped causes remain compatible with errors.Is.

SpendRequests.Retrieve is the exception to normal API-error handling: it returns nil, nil for HTTP 404. The SDK makes no automatic retry for transport errors, 429, or 5xx. Its only built-in retry is one 401 retry when a dynamic token provider returns a refreshed token.

Set IdempotencyKey (1–255 UTF-8 bytes, with no personal data) in CreateSpendRequestParams so a retried create returns the original response instead of a second request. Link rejects a duplicate create with HTTP 429 and the code spend_request_rate_limited, and the error details include the existing request. link.GetDuplicateSpendRequest returns that request as a *link.SpendRequest, or nil if the error isn’t a duplicate rejection:

spendRequest, err := client.SpendRequests.Create(ctx, params)
if err != nil {
	duplicate := link.GetDuplicateSpendRequest(err)
	if duplicate == nil {
		return err
	}
	// Continue with the existing request instead of creating another.
	spendRequest = duplicate
}

For other transient failures, use bounded backoff and your own operation tracking. After an uncertain create outcome, recover an existing request where possible instead of retrying blindly.

Manage spend requests

Use an authenticated client to update, request approval for, retrieve, list, or cancel a request. These examples show separate operations. Follow the spend request lifecycle to determine which operation applies, including how to increase the amount.

merchantURL := "https://press.stripe.com/working-in-public"
if _, err := client.SpendRequests.Update(ctx, "lsrq_001", link.UpdateSpendRequestParams{
	MerchantURL: &merchantURL,
}); err != nil {
	return err
}
 
if _, err := client.SpendRequests.RequestApproval(ctx, "lsrq_001"); err != nil {
	return err
}
if _, err := client.SpendRequests.Retrieve(ctx, "lsrq_001", nil); err != nil {
	return err
}
if _, err := client.SpendRequests.List(ctx, nil); err != nil {
	return err
}
if _, err := client.SpendRequests.Cancel(ctx, "lsrq_001"); err != nil {
	return err
}

Update a payment method

UpdatePaymentMethodParams has two pointer fields, Nickname and IsDefault. A nil pointer omits the field and leaves it unchanged. A pointer to "" or false explicitly clears the nickname or the default status.

setDefault := true
if _, err := client.PaymentMethods.Update(ctx, "pd_123", link.UpdatePaymentMethodParams{
	IsDefault: &setDefault,
}); err != nil {
	return err
}
 
// Clear the default status without changing the nickname.
clearDefault := false
if _, err := client.PaymentMethods.Update(ctx, "pd_123", link.UpdatePaymentMethodParams{
	IsDefault: &clearDefault,
}); err != nil {
	return err
}
 
// Clear the nickname without changing the default status.
emptyNickname := ""
if _, err := client.PaymentMethods.Update(ctx, "pd_123", link.UpdatePaymentMethodParams{
	Nickname: &emptyNickname,
}); err != nil {
	return err
}

Setting a default replaces the previous wallet default. This changes the customer’s wallet-level default, not just the payment method for one spend request, so do it only when the customer asks.

Report purchase outcomes

Use the reports resource to record a successful or blocked purchase attempt. Reporting is optional. See Outcome reporting for supported outcomes, tags, and personal-data handling.

if _, err := client.Reports.Create(ctx, link.CreateReportParams{
	Domain:         "shop.example.com",
	Outcome:        link.ReportOutcomeSuccess,
	SpendRequestID: "lsrq_abc123",
}); err != nil {
	return err
}
 
step := "checkout page"
freeformContext := "Turnstile challenge appeared"
if _, err := client.Reports.Create(ctx, link.CreateReportParams{
	Domain:          "shop.example.com",
	Outcome:         link.ReportOutcomeBlocked,
	SpendRequestID:  "lsrq_abc123",
	Tags:            []link.ReportTag{link.ReportTagCaptcha},
	Step:            &step,
	FreeformContext: &freeformContext,
}); err != nil {
	return err
}

Client resources

Use these resources on an authenticated client:

ResourceOperations
SpendRequestsCreate, request approval, retrieve, update, cancel, and list
PaymentMethodsList and update (nickname and default status) Link payment methods
ShippingAddressesList shipping addresses
UserInfoRetrieve Link user information, spend limits, and verification requirements
TransactionsList transactions
SourcesList connected sources
BalancesList balances
ReportsReport agent outcomes

The Go SDK doesn’t yet cover insights, or retrieving a single payment method. Use Link CLI or the TypeScript SDK for those.

Work with optional fields

Optional fields in request and response types are pointers. In request params, a nil slice omits the field and an empty, non-nil slice clears it. Some response types keep unrecognized fields in AdditionalFields, so new API fields don’t break decoding.

Review the main types

Optional fields are pointers. In request params, only Context on CreateSpendRequestParams is a plain required string.

TypeFields you use most
SpendRequestID, Status, ApprovalURL *string, Card *Card, SharedPaymentToken *SharedPaymentToken, LinkPayToken *string, PaymentStatusDetails *PaymentStatusDetails, StatusDetails *SpendRequestStatusDetails, ExpiresAt *int64, CreatedAt, UpdatedAt
CardID, Brand, Number, ExpMonth, ExpYear, CVC *string, BillingAddress *BillingAddress, ValidUntil *string
PaymentMethodID, Type, IsDefault, Name, Nickname *string, Capabilities map[string]ProductCapability
ProductCapabilityEligible bool, IneligibilityReasons []string
PaymentStatusDetailsOutcome, Code *string, DeclineCode *string, Amount, Currency, RefundDetails *RefundDetails
NextActionType, Resolution, DisplayMessage, ActionURL *string, ExpiresAt *int64
RequestApprovalResponseID, ApprovalURL

The request params are CreateSpendRequestParams, UpdateSpendRequestParams (PaymentDetails, Amount, MerchantURL, Currency, LineItems, Totals), ListSpendRequestsParams (IncludeHistory), and RetrieveSpendRequestParams (Include).

The SDK exports constants for the values you branch on:

  • Statuses: SpendRequestStatusCreated, SpendRequestStatusPendingApproval, SpendRequestStatusRequiresAction, SpendRequestStatusApproved, SpendRequestStatusDenied, SpendRequestStatusExpired, SpendRequestStatusCanceled, SpendRequestStatusSubmitted, SpendRequestStatusSucceeded, and SpendRequestStatusFailed.
  • Next-action resolutions: NextActionResolutionAutoResume, NextActionResolutionCreateNewSpendRequest, and NextActionResolutionCreateNewAfterActionCompletion.
  • Credential types: CredentialTypeCard, CredentialTypeSharedPaymentToken, and CredentialTypeLinkPayToken.

Versioning

The Go SDK is a nested module. Releases use tags in the form packages/sdk-go/vX.Y.Z and are distributed through the Go module proxy.