From 0f094b3f2d251c535aecd71101bbf5841cb18277 Mon Sep 17 00:00:00 2001
From: "kernel-internal[bot]"
<260533166+kernel-internal[bot]@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:03:11 +0000
Subject: [PATCH 1/2] feat: Support Link recurring spend requests on vault Link
cards
Stainless-Generated-From: bfdf48ecf725725b8b77f1c775dc51cf09cebe6b
---
api.md | 2 +
packages/pagination/pagination.go | 35 ++-
pagination_empty_page_test.go | 126 +++++++++
vaultitem.go | 443 +++++++++++++++++++++++++++---
vaultitem_test.go | 4 +
5 files changed, 564 insertions(+), 46 deletions(-)
create mode 100644 pagination_empty_page_test.go
diff --git a/api.md b/api.md
index fa85d489..895665a1 100644
--- a/api.md
+++ b/api.md
@@ -597,6 +597,8 @@ Response Types:
- kernel.KernelCredentialVaultItemState
- kernel.KernelWalletState
- kernel.KernelWalletVaultItemSpec
+- kernel.LinkShippingAddress
+- kernel.LinkUserInfo
- kernel.ManagedAuthCredentialVaultField
- kernel.ManagedAuthCredentialVaultItemSpec
- kernel.ManagedAuthCredentialVaultItemState
diff --git a/packages/pagination/pagination.go b/packages/pagination/pagination.go
index 9d81e76e..e2cfa88f 100644
--- a/packages/pagination/pagination.go
+++ b/packages/pagination/pagination.go
@@ -139,26 +139,27 @@ func (r *OffsetPagination[T]) UnmarshalJSON(data []byte) error {
// there is no next page, this function will return a 'nil' for the page value, but
// will not return an error
func (r *OffsetPagination[T]) GetNextPage() (res *OffsetPagination[T], err error) {
- if len(r.Items) == 0 {
- return nil, nil
- }
cfg := r.cfg.Clone(r.cfg.Context)
nextStr := r.res.Header.Get("X-Next-Offset")
+ if nextStr == "" && len(r.Items) == 0 {
+ return nil, nil
+ }
next, err := strconv.ParseInt(nextStr, 10, 64)
if err != nil {
return nil, err
}
- length := int64(len(r.Items))
- if length > 0 && next != 0 {
- err = cfg.Apply(option.WithQuery("offset", strconv.FormatInt(next, 10)))
- if err != nil {
- return nil, err
- }
- } else {
+ // An empty page can still have more pages behind it. Cursors can move in
+ // either direction, so only 0 or a repeated cursor ends pagination.
+ requested, _ := strconv.ParseInt(cfg.Request.URL.Query().Get("offset"), 10, 64)
+ if next == 0 || next == requested {
return nil, nil
}
+ err = cfg.Apply(option.WithQuery("offset", strconv.FormatInt(next, 10)))
+ if err != nil {
+ return nil, err
+ }
var raw *http.Response
cfg.ResponseInto = &raw
cfg.ResponseBodyInto = &res
@@ -195,13 +196,23 @@ func NewOffsetPaginationAutoPager[T any](page *OffsetPagination[T], err error) *
}
func (r *OffsetPaginationAutoPager[T]) Next() bool {
- if r.page == nil || len(r.page.Items) == 0 {
+ if r.page == nil {
return false
}
if r.idx >= len(r.page.Items) {
r.idx = 0
r.page, r.err = r.page.GetNextPage()
- if r.err != nil || r.page == nil || len(r.page.Items) == 0 {
+ if r.err != nil || r.page == nil {
+ return false
+ }
+ }
+ // if the API returned empty data then keep iterating
+ // until we either get more data or there are no more pages
+ // to fetch
+ for len(r.page.Items) == 0 {
+ r.idx = 0
+ r.page, r.err = r.page.GetNextPage()
+ if r.err != nil || r.page == nil {
return false
}
}
diff --git a/pagination_empty_page_test.go b/pagination_empty_page_test.go
new file mode 100644
index 00000000..06c7ec3a
--- /dev/null
+++ b/pagination_empty_page_test.go
@@ -0,0 +1,126 @@
+package kernel_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kernel/kernel-go-sdk"
+ "github.com/kernel/kernel-go-sdk/option"
+)
+
+type emptyPageResponse struct {
+ body string
+ headers map[string]string
+}
+
+// countEventsAcrossPages auto-pages the telemetry events endpoint against a
+// server that serves pages keyed by the request's offset query value.
+func countEventsAcrossPages(t *testing.T, pages map[string]emptyPageResponse) (events, requests int, err error) {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ page, ok := pages[r.URL.Query().Get("offset")]
+ if !ok {
+ t.Errorf("unexpected request for offset %q", r.URL.Query().Get("offset"))
+ http.Error(w, "unexpected offset", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ for k, v := range page.headers {
+ w.Header().Set(k, v)
+ }
+ fmt.Fprint(w, page.body)
+ }))
+ defer srv.Close()
+
+ client := kernel.NewClient(option.WithBaseURL(srv.URL), option.WithAPIKey("test-api-key"), option.WithMaxRetries(0))
+ iter := client.Browsers.Telemetry.EventsAutoPaging(context.Background(), "browser", kernel.BrowserTelemetryEventsParams{})
+ for iter.Next() {
+ events++
+ }
+ return events, requests, iter.Err()
+}
+
+const emptyPageEvent = `{"seq":1,"event":{"ts":1,"type":"page_load","category":"page","source":{"kind":"kernel_api"},"data":{}}}`
+
+func TestOffsetPaginationContinuesPastEmptyPages(t *testing.T) {
+ events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{
+ "": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}},
+ "20": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "40"}},
+ "40": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "60"}},
+ "60": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if events != 2 || requests != 4 {
+ t.Fatalf("got %d events in %d requests, want 2 events in 4 requests", events, requests)
+ }
+}
+
+func TestOffsetPaginationEmptyFirstPageContinues(t *testing.T) {
+ events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{
+ "": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}},
+ "20": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if events != 1 || requests != 2 {
+ t.Fatalf("got %d events in %d requests, want 1 event in 2 requests", events, requests)
+ }
+}
+
+func TestOffsetPaginationStopsOnEmptyLastPage(t *testing.T) {
+ events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{
+ "": {"[]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if events != 0 || requests != 1 {
+ t.Fatalf("got %d events in %d requests, want 0 events in 1 request", events, requests)
+ }
+}
+
+func TestOffsetPaginationStopsOnEmptyPageWithoutCursor(t *testing.T) {
+ events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{
+ "": {"[]", nil},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if events != 0 || requests != 1 {
+ t.Fatalf("got %d events in %d requests, want 0 events in 1 request", events, requests)
+ }
+}
+
+func TestOffsetPaginationStopsWhenCursorDoesNotAdvance(t *testing.T) {
+ events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{
+ "": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}},
+ "20": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if events != 1 || requests != 2 {
+ t.Fatalf("got %d events in %d requests, want 1 event in 2 requests", events, requests)
+ }
+}
+
+func TestOffsetPaginationFollowsDescendingCursorAcrossEmptyPage(t *testing.T) {
+ events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{
+ "": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "300"}},
+ "300": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "200"}},
+ "200": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if events != 2 || requests != 3 {
+ t.Fatalf("got %d events in %d requests, want 2 events in 3 requests", events, requests)
+ }
+}
diff --git a/vaultitem.go b/vaultitem.go
index 69bcfe80..eca262c4 100644
--- a/vaultitem.go
+++ b/vaultitem.go
@@ -451,6 +451,8 @@ type CardVaultItemSpecUnion struct {
// This field is from variant [CardVaultItemSpecLink].
Metadata map[string]string `json:"metadata"`
// This field is from variant [CardVaultItemSpecLink].
+ Recurring CardVaultItemSpecLinkRecurring `json:"recurring"`
+ // This field is from variant [CardVaultItemSpecLink].
Totals []CardVaultItemSpecLinkTotal `json:"totals"`
// This field is from variant [CardVaultItemSpecAgentcard].
Merchant string `json:"merchant"`
@@ -475,6 +477,7 @@ type CardVaultItemSpecUnion struct {
Wallet respjson.Field
LineItems respjson.Field
Metadata respjson.Field
+ Recurring respjson.Field
Totals respjson.Field
Merchant respjson.Field
CardID respjson.Field
@@ -555,7 +558,8 @@ func (r CardVaultItemSpecUnion) ToParam() CardVaultItemSpecUnionParam {
// virtual card otherwise. Callers never receive or choose how the credential is
// delivered.
type CardVaultItemSpecLink struct {
- // Integer amount in minor currency units, at most 50000.
+ // Integer amount in minor currency units, at most 50000. With recurring, the
+ // spending limit per period, not a lifetime total.
Amount int64 `json:"amount" api:"required"`
Context string `json:"context" api:"required"`
Currency string `json:"currency" api:"required"`
@@ -570,7 +574,17 @@ type CardVaultItemSpecLink struct {
Wallet string `json:"wallet" api:"required"`
LineItems []CardVaultItemSpecLinkLineItem `json:"line_items"`
Metadata map[string]string `json:"metadata"`
- Totals []CardVaultItemSpecLinkTotal `json:"totals"`
+ // Recurring terms for a subscription or other repeated purchase. The amount
+ // becomes the spending limit for each period of interval_count intervals, for
+ // example every 2 weeks. The merchant schedules charges. The card stays ready
+ // after each charge until it expires or is deleted; do not create a new card each
+ // period. Deleting the card, its wallet or its vault first cancels the credential
+ // with Link, so later charges decline; if Link cannot be reached, deletion returns
+ // 409 provider_revocation_pending and should be retried. Deletion does not cancel
+ // the subscription with the merchant. Recurring terms cannot be changed once the
+ // card is authorized.
+ Recurring CardVaultItemSpecLinkRecurring `json:"recurring"`
+ Totals []CardVaultItemSpecLinkTotal `json:"totals"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
Amount respjson.Field
@@ -583,6 +597,7 @@ type CardVaultItemSpecLink struct {
Wallet respjson.Field
LineItems respjson.Field
Metadata respjson.Field
+ Recurring respjson.Field
Totals respjson.Field
ExtraFields map[string]respjson.Field
raw string
@@ -649,6 +664,35 @@ func (r *CardVaultItemSpecLinkLineItemTotal) UnmarshalJSON(data []byte) error {
return apijson.UnmarshalRoot(data, r)
}
+// Recurring terms for a subscription or other repeated purchase. The amount
+// becomes the spending limit for each period of interval_count intervals, for
+// example every 2 weeks. The merchant schedules charges. The card stays ready
+// after each charge until it expires or is deleted; do not create a new card each
+// period. Deleting the card, its wallet or its vault first cancels the credential
+// with Link, so later charges decline; if Link cannot be reached, deletion returns
+// 409 provider_revocation_pending and should be retried. Deletion does not cancel
+// the subscription with the merchant. Recurring terms cannot be changed once the
+// card is authorized.
+type CardVaultItemSpecLinkRecurring struct {
+ // Any of "day", "week", "month", "year".
+ Interval string `json:"interval" api:"required"`
+ // Number of intervals per period. Link defaults it to 1.
+ IntervalCount int64 `json:"interval_count"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ Interval respjson.Field
+ IntervalCount respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r CardVaultItemSpecLinkRecurring) RawJSON() string { return r.JSON.raw }
+func (r *CardVaultItemSpecLinkRecurring) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
type CardVaultItemSpecLinkTotal struct {
// Total amount in minor currency units.
Amount int64 `json:"amount" api:"required"`
@@ -776,6 +820,14 @@ func (u CardVaultItemSpecUnionParam) GetMetadata() map[string]string {
return nil
}
+// Returns a pointer to the underlying variant's property, if present.
+func (u CardVaultItemSpecUnionParam) GetRecurring() *CardVaultItemSpecLinkRecurringParam {
+ if vt := u.OfLink; vt != nil {
+ return &vt.Recurring
+ }
+ return nil
+}
+
// Returns a pointer to the underlying variant's property, if present.
func (u CardVaultItemSpecUnionParam) GetTotals() []CardVaultItemSpecLinkTotalParam {
if vt := u.OfLink; vt != nil {
@@ -919,7 +971,8 @@ func init() {
// The properties Amount, Context, Currency, MerchantName, MerchantURL,
// PaymentMethodID, Provider, Wallet are required.
type CardVaultItemSpecLinkParam struct {
- // Integer amount in minor currency units, at most 50000.
+ // Integer amount in minor currency units, at most 50000. With recurring, the
+ // spending limit per period, not a lifetime total.
Amount int64 `json:"amount" api:"required"`
Context string `json:"context" api:"required"`
Currency string `json:"currency" api:"required"`
@@ -933,7 +986,17 @@ type CardVaultItemSpecLinkParam struct {
Wallet string `json:"wallet" api:"required"`
LineItems []CardVaultItemSpecLinkLineItemParam `json:"line_items,omitzero"`
Metadata map[string]string `json:"metadata,omitzero"`
- Totals []CardVaultItemSpecLinkTotalParam `json:"totals,omitzero"`
+ // Recurring terms for a subscription or other repeated purchase. The amount
+ // becomes the spending limit for each period of interval_count intervals, for
+ // example every 2 weeks. The merchant schedules charges. The card stays ready
+ // after each charge until it expires or is deleted; do not create a new card each
+ // period. Deleting the card, its wallet or its vault first cancels the credential
+ // with Link, so later charges decline; if Link cannot be reached, deletion returns
+ // 409 provider_revocation_pending and should be retried. Deletion does not cancel
+ // the subscription with the merchant. Recurring terms cannot be changed once the
+ // card is authorized.
+ Recurring CardVaultItemSpecLinkRecurringParam `json:"recurring,omitzero"`
+ Totals []CardVaultItemSpecLinkTotalParam `json:"totals,omitzero"`
// This field can be elided, and will marshal its zero value as "link".
Provider constant.Link `json:"provider" default:"link"`
paramObj
@@ -987,6 +1050,39 @@ func (r *CardVaultItemSpecLinkLineItemTotalParam) UnmarshalJSON(data []byte) err
return apijson.UnmarshalRoot(data, r)
}
+// Recurring terms for a subscription or other repeated purchase. The amount
+// becomes the spending limit for each period of interval_count intervals, for
+// example every 2 weeks. The merchant schedules charges. The card stays ready
+// after each charge until it expires or is deleted; do not create a new card each
+// period. Deleting the card, its wallet or its vault first cancels the credential
+// with Link, so later charges decline; if Link cannot be reached, deletion returns
+// 409 provider_revocation_pending and should be retried. Deletion does not cancel
+// the subscription with the merchant. Recurring terms cannot be changed once the
+// card is authorized.
+//
+// The property Interval is required.
+type CardVaultItemSpecLinkRecurringParam struct {
+ // Any of "day", "week", "month", "year".
+ Interval string `json:"interval,omitzero" api:"required"`
+ // Number of intervals per period. Link defaults it to 1.
+ IntervalCount param.Opt[int64] `json:"interval_count,omitzero"`
+ paramObj
+}
+
+func (r CardVaultItemSpecLinkRecurringParam) MarshalJSON() (data []byte, err error) {
+ type shadow CardVaultItemSpecLinkRecurringParam
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *CardVaultItemSpecLinkRecurringParam) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+func init() {
+ apijson.RegisterFieldValidator[CardVaultItemSpecLinkRecurringParam](
+ "interval", "day", "week", "month", "year",
+ )
+}
+
// The properties Amount, DisplayText, Type are required.
type CardVaultItemSpecLinkTotalParam struct {
// Total amount in minor currency units.
@@ -1421,7 +1517,7 @@ func (r *CredentialAccountVaultItem) UnmarshalJSON(data []byte) error {
// expand parameter.
type CredentialAccountVaultItemAvailableExpansion struct {
Description string `json:"description" api:"required"`
- // Any of "payment_methods".
+ // Any of "payment_methods", "link_user_info", "link_shipping_addresses".
Type string `json:"type" api:"required"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
@@ -1860,7 +1956,7 @@ func (r *CredentialVaultItem) UnmarshalJSON(data []byte) error {
// expand parameter.
type CredentialVaultItemAvailableExpansion struct {
Description string `json:"description" api:"required"`
- // Any of "payment_methods".
+ // Any of "payment_methods", "link_user_info", "link_shipping_addresses".
Type string `json:"type" api:"required"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
@@ -2475,7 +2571,10 @@ type FillVaultItemOperationRequestParam struct {
// prefix or glob matching. When an item allows omitting it, the browser must have
// exactly one open page.
PageURL param.Opt[string] `json:"page_url,omitzero" format:"uri"`
- // Total operation deadline in milliseconds, not a per-field timeout.
+ // Ignored. Fill sets no operation deadline; typing pace adapts to the length of
+ // the values instead. Accepted for compatibility.
+ //
+ // Deprecated: deprecated
TimeoutMs param.Opt[int64] `json:"timeout_ms,omitzero"`
// Field bindings, when the item's advertised fill operation requires them. Each
// selector is searched in the main frame and all descendant frames and must
@@ -2559,8 +2658,11 @@ const (
type KernelCardState struct {
// Any of "kernel".
Provider KernelCardStateProvider `json:"provider" api:"required"`
- // pending_authorization on a Visa purchase waits for the cardholder to approve it
- // through the spend_approval action; an unused link expires after 30 minutes.
+ // pending_authorization waits for cardholder approval on Visa purchases or fresh
+ // security-code collection on alias-mode purchases through the spend_approval
+ // action; an unused link expires after 30 minutes. Alias-mode cards become ready
+ // after collection and expire at the temporary security-code alias deadline, by
+ // default five minutes later. An expired alias purchase requires a new card.
// recovery_required means verification, approving the purchase or issuing the
// one-time code has an unresolved outcome. A server-classified initial attestation
// failure before intent dispatch may advertise restart_verification to prepare a
@@ -2605,8 +2707,11 @@ const (
KernelCardStateProviderKernel KernelCardStateProvider = "kernel"
)
-// pending_authorization on a Visa purchase waits for the cardholder to approve it
-// through the spend_approval action; an unused link expires after 30 minutes.
+// pending_authorization waits for cardholder approval on Visa purchases or fresh
+// security-code collection on alias-mode purchases through the spend_approval
+// action; an unused link expires after 30 minutes. Alias-mode cards become ready
+// after collection and expire at the temporary security-code alias deadline, by
+// default five minutes later. An expired alias purchase requires a new card.
// recovery_required means verification, approving the purchase or issuing the
// one-time code has an unresolved outcome. A server-classified initial attestation
// failure before intent dispatch may advertise restart_verification to prepare a
@@ -2662,12 +2767,13 @@ func (r *KernelCardStateMasks) UnmarshalJSON(data []byte) error {
// spend_approval action: the cardholder approves it with a Visa passkey, and
// Kernel registers a Visa intent for one transaction up to the amount before
// issuing the code. For supported cards whose network cannot be enrolled for
-// agentic tokens, authorize prepares checkout details without issuing a one-time
-// code. The card can be filled into one checkout before its expires_at, one hour
-// after authorization. Fill types those details on merchant_url's origin. The
-// amount, merchant_url and merchant_country are recorded but do not restrict
-// payment requests sent by the browser. Card updates are not supported; delete and
-// create a new item instead.
+// agentic tokens, authorize returns pending_authorization with a spend_approval
+// action to collect a fresh security code in a hosted field. Once collected, the
+// card is ready and can be filled before its expires_at, by default five minutes
+// after collection. An expired card requires a new purchase. Fill types those
+// details on merchant_url's origin. The amount, merchant_url and merchant_country
+// are recorded but do not restrict payment requests sent by the browser. Card
+// updates are not supported; delete and create a new item instead.
type KernelCardVaultItemSpec struct {
// Integer amount in minor currency units (at most 1000000), bound to the one-time
// code.
@@ -2736,12 +2842,13 @@ const (
// spend_approval action: the cardholder approves it with a Visa passkey, and
// Kernel registers a Visa intent for one transaction up to the amount before
// issuing the code. For supported cards whose network cannot be enrolled for
-// agentic tokens, authorize prepares checkout details without issuing a one-time
-// code. The card can be filled into one checkout before its expires_at, one hour
-// after authorization. Fill types those details on merchant_url's origin. The
-// amount, merchant_url and merchant_country are recorded but do not restrict
-// payment requests sent by the browser. Card updates are not supported; delete and
-// create a new item instead.
+// agentic tokens, authorize returns pending_authorization with a spend_approval
+// action to collect a fresh security code in a hosted field. Once collected, the
+// card is ready and can be filled before its expires_at, by default five minutes
+// after collection. An expired card requires a new purchase. Fill types those
+// details on merchant_url's origin. The amount, merchant_url and merchant_country
+// are recorded but do not restrict payment requests sent by the browser. Card
+// updates are not supported; delete and create a new item instead.
//
// The properties Amount, Currency, MerchantName, MerchantURL, Provider, Wallet are
// required.
@@ -3006,6 +3113,248 @@ func (r *KernelWalletVaultItemSpecParam) UnmarshalJSON(data []byte) error {
return apijson.UnmarshalRoot(data, r)
}
+// One entry of Link's `GET /shipping_addresses` response, passed through as Link
+// reports it. Kernel never stores it.
+type LinkShippingAddress struct {
+ // Link shipping address ID.
+ ID string `json:"id" api:"required"`
+ IsDefault bool `json:"is_default" api:"required"`
+ Nickname string `json:"nickname" api:"required"`
+ // Link's nullable postal address fields. Omitted from the shipping address when
+ // Link reports null.
+ Address LinkShippingAddressAddress `json:"address"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ ID respjson.Field
+ IsDefault respjson.Field
+ Nickname respjson.Field
+ Address respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkShippingAddress) RawJSON() string { return r.JSON.raw }
+func (r *LinkShippingAddress) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+// Link's nullable postal address fields. Omitted from the shipping address when
+// Link reports null.
+type LinkShippingAddressAddress struct {
+ // State, province or region.
+ AdministrativeArea string `json:"administrative_area" api:"required"`
+ CountryCode string `json:"country_code" api:"required"`
+ DependentLocality string `json:"dependent_locality" api:"required"`
+ Line1 string `json:"line_1" api:"required"`
+ Line2 string `json:"line_2" api:"required"`
+ // City or town.
+ Locality string `json:"locality" api:"required"`
+ Name string `json:"name" api:"required"`
+ PostalCode string `json:"postal_code" api:"required"`
+ SortingCode string `json:"sorting_code" api:"required"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ AdministrativeArea respjson.Field
+ CountryCode respjson.Field
+ DependentLocality respjson.Field
+ Line1 respjson.Field
+ Line2 respjson.Field
+ Locality respjson.Field
+ Name respjson.Field
+ PostalCode respjson.Field
+ SortingCode respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkShippingAddressAddress) RawJSON() string { return r.JSON.raw }
+func (r *LinkShippingAddressAddress) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+// Link's `GET /userinfo` response for the connected wallet, passed through as Link
+// reports it when the `link_user_info` expansion is requested. Kernel never stores
+// it. Null fields are null at Link; omitted fields were not reported. Amounts are
+// in the minor unit of `default_spend_request_currency`.
+type LinkUserInfo struct {
+ // Stable Link user ID.
+ ID string `json:"id" api:"required"`
+ Email string `json:"email" api:"required"`
+ FirstName string `json:"first_name" api:"required"`
+ LastName string `json:"last_name" api:"required"`
+ Name string `json:"name" api:"required"`
+ Phone string `json:"phone" api:"required"`
+ // The Link user's postal address. Omitted when Link reports none.
+ Address LinkUserInfoAddress `json:"address"`
+ // Link's agent wallet spend limits, per transaction and over rolling periods.
+ AgentWalletSpendLimits LinkUserInfoAgentWalletSpendLimits `json:"agent_wallet_spend_limits"`
+ // Verification Link requires from the user before the agent wallet can spend.
+ AgentWalletStepUp LinkUserInfoAgentWalletStepUp `json:"agent_wallet_step_up"`
+ // Lowercase ISO currency Link applies to a spend request created without one.
+ DefaultSpendRequestCurrency string `json:"default_spend_request_currency"`
+ // Whether the user can fund purchases from a Link balance.
+ EligibleForBalance bool `json:"eligible_for_balance"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ ID respjson.Field
+ Email respjson.Field
+ FirstName respjson.Field
+ LastName respjson.Field
+ Name respjson.Field
+ Phone respjson.Field
+ Address respjson.Field
+ AgentWalletSpendLimits respjson.Field
+ AgentWalletStepUp respjson.Field
+ DefaultSpendRequestCurrency respjson.Field
+ EligibleForBalance respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkUserInfo) RawJSON() string { return r.JSON.raw }
+func (r *LinkUserInfo) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+// The Link user's postal address. Omitted when Link reports none.
+type LinkUserInfoAddress struct {
+ City string `json:"city" api:"required"`
+ Country string `json:"country" api:"required"`
+ Line1 string `json:"line1" api:"required"`
+ Line2 string `json:"line2" api:"required"`
+ PostalCode string `json:"postal_code" api:"required"`
+ State string `json:"state" api:"required"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ City respjson.Field
+ Country respjson.Field
+ Line1 respjson.Field
+ Line2 respjson.Field
+ PostalCode respjson.Field
+ State respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkUserInfoAddress) RawJSON() string { return r.JSON.raw }
+func (r *LinkUserInfoAddress) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+// Link's agent wallet spend limits, per transaction and over rolling periods.
+type LinkUserInfoAgentWalletSpendLimits struct {
+ Daily LinkUserInfoAgentWalletSpendLimitsDaily `json:"daily" api:"required"`
+ PerTransaction LinkUserInfoAgentWalletSpendLimitsPerTransaction `json:"per_transaction" api:"required"`
+ ThirtyDay LinkUserInfoAgentWalletSpendLimitsThirtyDay `json:"thirty_day" api:"required"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ Daily respjson.Field
+ PerTransaction respjson.Field
+ ThirtyDay respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkUserInfoAgentWalletSpendLimits) RawJSON() string { return r.JSON.raw }
+func (r *LinkUserInfoAgentWalletSpendLimits) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+type LinkUserInfoAgentWalletSpendLimitsDaily struct {
+ // Null means unlimited.
+ Limit int64 `json:"limit" api:"required"`
+ // Amount still available in the rolling period; null means unlimited.
+ Remaining int64 `json:"remaining" api:"required"`
+ // Amount already spent in the rolling period.
+ Used int64 `json:"used" api:"required"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ Limit respjson.Field
+ Remaining respjson.Field
+ Used respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkUserInfoAgentWalletSpendLimitsDaily) RawJSON() string { return r.JSON.raw }
+func (r *LinkUserInfoAgentWalletSpendLimitsDaily) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+type LinkUserInfoAgentWalletSpendLimitsPerTransaction struct {
+ // Null means unlimited.
+ Limit int64 `json:"limit" api:"required"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ Limit respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkUserInfoAgentWalletSpendLimitsPerTransaction) RawJSON() string { return r.JSON.raw }
+func (r *LinkUserInfoAgentWalletSpendLimitsPerTransaction) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+type LinkUserInfoAgentWalletSpendLimitsThirtyDay struct {
+ // Null means unlimited.
+ Limit int64 `json:"limit" api:"required"`
+ // Amount still available in the rolling period; null means unlimited.
+ Remaining int64 `json:"remaining" api:"required"`
+ // Amount already spent in the rolling period.
+ Used int64 `json:"used" api:"required"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ Limit respjson.Field
+ Remaining respjson.Field
+ Used respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkUserInfoAgentWalletSpendLimitsThirtyDay) RawJSON() string { return r.JSON.raw }
+func (r *LinkUserInfoAgentWalletSpendLimitsThirtyDay) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+// Verification Link requires from the user before the agent wallet can spend.
+type LinkUserInfoAgentWalletStepUp struct {
+ // URL where the user completes verification; null when no action is available.
+ ActionURL string `json:"action_url" api:"required"`
+ // Link status such as not_required, ssn_verification, identity_verification,
+ // contact_support or complete.
+ Status string `json:"status" api:"required"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ ActionURL respjson.Field
+ Status respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r LinkUserInfoAgentWalletStepUp) RawJSON() string { return r.JSON.raw }
+func (r *LinkUserInfoAgentWalletStepUp) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
type ManagedAuthCredentialVaultField struct {
// Text, email, and password have form inputs; totp and passkey do not and are
// omitted from both Kernel-hosted and customer React forms. Password, totp, and
@@ -4376,6 +4725,8 @@ type VaultItemUnionSpec struct {
// This field is from variant [CardVaultItemSpecUnion].
Metadata map[string]string `json:"metadata"`
// This field is from variant [CardVaultItemSpecUnion].
+ Recurring CardVaultItemSpecLinkRecurring `json:"recurring"`
+ // This field is from variant [CardVaultItemSpecUnion].
Totals []CardVaultItemSpecLinkTotal `json:"totals"`
// This field is from variant [CardVaultItemSpecUnion].
Merchant string `json:"merchant"`
@@ -4414,6 +4765,7 @@ type VaultItemUnionSpec struct {
Wallet respjson.Field
LineItems respjson.Field
Metadata respjson.Field
+ Recurring respjson.Field
Totals respjson.Field
Merchant respjson.Field
CardID respjson.Field
@@ -4665,7 +5017,7 @@ func (r *VaultItemWallet) UnmarshalJSON(data []byte) error {
// expand parameter.
type VaultItemWalletAvailableExpansion struct {
Description string `json:"description" api:"required"`
- // Any of "payment_methods".
+ // Any of "payment_methods", "link_user_info", "link_shipping_addresses".
Type string `json:"type" api:"required"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
@@ -4708,12 +5060,22 @@ func (r *VaultItemWalletAvailableOperation) UnmarshalJSON(data []byte) error {
// Live, non-persisted data requested through the item GET expand parameter.
type VaultItemWalletExpanded struct {
+ // Link's saved shipping addresses, present when the `link_shipping_addresses`
+ // expansion is requested.
+ LinkShippingAddresses []LinkShippingAddress `json:"link_shipping_addresses"`
+ // Link's `GET /userinfo` response for the connected wallet, passed through as Link
+ // reports it when the `link_user_info` expansion is requested. Kernel never stores
+ // it. Null fields are null at Link; omitted fields were not reported. Amounts are
+ // in the minor unit of `default_spend_request_currency`.
+ LinkUserInfo LinkUserInfo `json:"link_user_info"`
PaymentMethods []VaultPaymentMethod `json:"payment_methods"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
- PaymentMethods respjson.Field
- ExtraFields map[string]respjson.Field
- raw string
+ LinkShippingAddresses respjson.Field
+ LinkUserInfo respjson.Field
+ PaymentMethods respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
} `json:"-"`
}
@@ -4772,7 +5134,7 @@ func (r *VaultItemCard) UnmarshalJSON(data []byte) error {
// expand parameter.
type VaultItemCardAvailableExpansion struct {
Description string `json:"description" api:"required"`
- // Any of "payment_methods".
+ // Any of "payment_methods", "link_user_info", "link_shipping_addresses".
Type string `json:"type" api:"required"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
@@ -5314,6 +5676,8 @@ type VaultItemOperationResponseUnionSpec struct {
// This field is from variant [CardVaultItemSpecUnion].
Metadata map[string]string `json:"metadata"`
// This field is from variant [CardVaultItemSpecUnion].
+ Recurring CardVaultItemSpecLinkRecurring `json:"recurring"`
+ // This field is from variant [CardVaultItemSpecUnion].
Totals []CardVaultItemSpecLinkTotal `json:"totals"`
// This field is from variant [CardVaultItemSpecUnion].
Merchant string `json:"merchant"`
@@ -5352,6 +5716,7 @@ type VaultItemOperationResponseUnionSpec struct {
Wallet respjson.Field
LineItems respjson.Field
Metadata respjson.Field
+ Recurring respjson.Field
Totals respjson.Field
Merchant respjson.Field
CardID respjson.Field
@@ -5606,7 +5971,7 @@ func (r *VaultItemOperationResponseWalletVaultItem) UnmarshalJSON(data []byte) e
// expand parameter.
type VaultItemOperationResponseWalletVaultItemAvailableExpansion struct {
Description string `json:"description" api:"required"`
- // Any of "payment_methods".
+ // Any of "payment_methods", "link_user_info", "link_shipping_addresses".
Type string `json:"type" api:"required"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
@@ -5653,12 +6018,22 @@ func (r *VaultItemOperationResponseWalletVaultItemAvailableOperation) UnmarshalJ
// Live, non-persisted data requested through the item GET expand parameter.
type VaultItemOperationResponseWalletVaultItemExpanded struct {
+ // Link's saved shipping addresses, present when the `link_shipping_addresses`
+ // expansion is requested.
+ LinkShippingAddresses []LinkShippingAddress `json:"link_shipping_addresses"`
+ // Link's `GET /userinfo` response for the connected wallet, passed through as Link
+ // reports it when the `link_user_info` expansion is requested. Kernel never stores
+ // it. Null fields are null at Link; omitted fields were not reported. Amounts are
+ // in the minor unit of `default_spend_request_currency`.
+ LinkUserInfo LinkUserInfo `json:"link_user_info"`
PaymentMethods []VaultPaymentMethod `json:"payment_methods"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
- PaymentMethods respjson.Field
- ExtraFields map[string]respjson.Field
- raw string
+ LinkShippingAddresses respjson.Field
+ LinkUserInfo respjson.Field
+ PaymentMethods respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
} `json:"-"`
}
@@ -5718,7 +6093,7 @@ func (r *VaultItemOperationResponseCardVaultItem) UnmarshalJSON(data []byte) err
// expand parameter.
type VaultItemOperationResponseCardVaultItemAvailableExpansion struct {
Description string `json:"description" api:"required"`
- // Any of "payment_methods".
+ // Any of "payment_methods", "link_user_info", "link_shipping_addresses".
Type string `json:"type" api:"required"`
// JSON contains metadata for fields, check presence with [respjson.Field.Valid].
JSON struct {
@@ -6409,7 +6784,7 @@ type VaultItemGetParams struct {
Wait param.Opt[int64] `query:"wait,omitzero" json:"-"`
// Live fields advertised by `available_expansions` to include in `expanded`.
//
- // Any of "payment_methods".
+ // Any of "payment_methods", "link_user_info", "link_shipping_addresses".
Expand []string `query:"expand,omitzero" json:"-"`
paramObj
}
diff --git a/vaultitem_test.go b/vaultitem_test.go
index 270543ca..fcbc0fa1 100644
--- a/vaultitem_test.go
+++ b/vaultitem_test.go
@@ -90,6 +90,10 @@ func TestVaultItemUpdateWithOptionalParams(t *testing.T) {
Metadata: map[string]string{
"foo": "string",
},
+ Recurring: kernel.CardVaultItemSpecLinkRecurringParam{
+ Interval: "day",
+ IntervalCount: kernel.Int(1),
+ },
Totals: []kernel.CardVaultItemSpecLinkTotalParam{{
Amount: 0,
DisplayText: "display_text",
From 9e61b83a44d36cf3653077f8b36ca56ae34dd174 Mon Sep 17 00:00:00 2001
From: "kernel-internal[bot]"
<260533166+kernel-internal[bot]@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:08:25 +0000
Subject: [PATCH 2/2] release: 0.130.0
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 7 +++++++
README.md | 2 +-
internal/version.go | 2 +-
4 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 65b6e7b4..37e99723 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.129.0"
+ ".": "0.130.0"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa086c90..b5388b97 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.130.0](https://github.com/kernel/kernel-go-sdk/compare/v0.129.0...v0.130.0) (2026-10-24)
+
+
+### Features
+
+* Support Link recurring spend requests on vault Link cards ([0f094b3](https://github.com/kernel/kernel-go-sdk/commit/0f094b3f2d251c535aecd71101bbf5841cb18277))
+
## [0.129.0](https://github.com/kernel/kernel-go-sdk/compare/v0.128.0...v0.129.0) (2026-10-21)
diff --git a/README.md b/README.md
index 54e7a130..9dfc55ed 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ Or to pin the version:
```sh
-go get -u 'github.com/kernel/kernel-go-sdk@v0.129.0'
+go get -u 'github.com/kernel/kernel-go-sdk@v0.130.0'
```
diff --git a/internal/version.go b/internal/version.go
index dae76fee..7f40f5ec 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -2,4 +2,4 @@
package internal
-const PackageVersion = "0.129.0" // x-release-please-version
+const PackageVersion = "0.130.0" // x-release-please-version