@agent-cards/sdk 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/PREFLIGHT.md +314 -0
  3. package/README.md +864 -0
  4. package/dist/adyen-merchant-hosted.generated.d.ts +277 -0
  5. package/dist/adyen-merchant-hosted.generated.js +1902 -0
  6. package/dist/adyen.generated.d.ts +24 -0
  7. package/dist/adyen.generated.js +64 -0
  8. package/dist/agentcard.d.ts +72 -0
  9. package/dist/agentcard.js +71 -0
  10. package/dist/api-module.d.ts +54 -0
  11. package/dist/api-module.js +85 -0
  12. package/dist/attachment.d.ts +11 -0
  13. package/dist/attachment.js +50 -0
  14. package/dist/braintree.d.ts +2 -0
  15. package/dist/braintree.generated.d.ts +10 -0
  16. package/dist/braintree.generated.js +302 -0
  17. package/dist/braintree.js +2 -0
  18. package/dist/builtin-registry.generated.d.ts +2 -0
  19. package/dist/builtin-registry.generated.js +1 -0
  20. package/dist/buy.d.ts +374 -0
  21. package/dist/buy.js +314 -0
  22. package/dist/card-fields.generated.d.ts +3 -0
  23. package/dist/card-fields.generated.js +88 -0
  24. package/dist/cdp.d.ts +202 -0
  25. package/dist/cdp.js +2540 -0
  26. package/dist/checkout-com.generated.d.ts +4 -0
  27. package/dist/checkout-com.generated.js +183 -0
  28. package/dist/client.d.ts +929 -0
  29. package/dist/client.js +1896 -0
  30. package/dist/cse-body.d.ts +25 -0
  31. package/dist/cse-body.js +41 -0
  32. package/dist/fiserv.d.ts +65 -0
  33. package/dist/fiserv.generated.d.ts +73 -0
  34. package/dist/fiserv.generated.js +830 -0
  35. package/dist/fiserv.js +104 -0
  36. package/dist/hosted-form.d.ts +44 -0
  37. package/dist/hosted-form.js +78 -0
  38. package/dist/index.d.ts +37 -0
  39. package/dist/index.js +26 -0
  40. package/dist/languages.d.ts +23 -0
  41. package/dist/languages.js +76 -0
  42. package/dist/lifecycle.d.ts +235 -0
  43. package/dist/lifecycle.js +405 -0
  44. package/dist/mercado-checkout.d.ts +20 -0
  45. package/dist/mercado-checkout.generated.d.ts +52 -0
  46. package/dist/mercado-checkout.generated.js +198 -0
  47. package/dist/mercado-checkout.js +108 -0
  48. package/dist/merchant-handoff.d.ts +54 -0
  49. package/dist/merchant-handoff.js +100 -0
  50. package/dist/merchant-hosted.d.ts +140 -0
  51. package/dist/merchant-hosted.js +170 -0
  52. package/dist/merchant-total-watch.d.ts +115 -0
  53. package/dist/merchant-total-watch.js +268 -0
  54. package/dist/merchant-total.d.ts +257 -0
  55. package/dist/merchant-total.js +383 -0
  56. package/dist/owned-shop.generated.d.ts +24 -0
  57. package/dist/owned-shop.generated.js +108 -0
  58. package/dist/paysafe.generated.d.ts +12 -0
  59. package/dist/paysafe.generated.js +87 -0
  60. package/dist/playwright.d.ts +3 -0
  61. package/dist/playwright.js +3 -0
  62. package/dist/pre-claim.d.ts +123 -0
  63. package/dist/pre-claim.js +386 -0
  64. package/dist/preflight-capabilities.generated.d.ts +1311 -0
  65. package/dist/preflight-capabilities.generated.js +2007 -0
  66. package/dist/preflight-catalog.json +5031 -0
  67. package/dist/preflight-playwright.d.ts +34 -0
  68. package/dist/preflight-playwright.js +355 -0
  69. package/dist/preflight-schemas.json +1144 -0
  70. package/dist/preflight.d.ts +1 -0
  71. package/dist/preflight.generated.d.ts +2023 -0
  72. package/dist/preflight.generated.js +604 -0
  73. package/dist/preflight.js +2 -0
  74. package/dist/preparation.d.ts +38 -0
  75. package/dist/preparation.js +191 -0
  76. package/dist/prepared-processor.d.ts +43 -0
  77. package/dist/prepared-processor.js +172 -0
  78. package/dist/recurly.generated.d.ts +1 -0
  79. package/dist/recurly.generated.js +87 -0
  80. package/dist/registry.d.ts +145 -0
  81. package/dist/registry.js +338 -0
  82. package/dist/requests.d.ts +66 -0
  83. package/dist/requests.js +76 -0
  84. package/dist/spreedly.generated.d.ts +10 -0
  85. package/dist/spreedly.generated.js +332 -0
  86. package/dist/stripe-checkout.d.ts +81 -0
  87. package/dist/stripe-checkout.generated.d.ts +82 -0
  88. package/dist/stripe-checkout.generated.js +1153 -0
  89. package/dist/stripe-checkout.js +140 -0
  90. package/dist/substitute.d.ts +38 -0
  91. package/dist/substitute.js +23 -0
  92. package/dist/substitutions.generated.d.ts +11 -0
  93. package/dist/substitutions.generated.js +818 -0
  94. package/dist/voice-pkce.d.ts +49 -0
  95. package/dist/voice-pkce.js +147 -0
  96. package/dist/voice-retry.d.ts +6 -0
  97. package/dist/voice-retry.js +27 -0
  98. package/dist/voice-stream.generated.d.ts +118 -0
  99. package/dist/voice-stream.generated.js +170 -0
  100. package/dist/voice-types.d.ts +708 -0
  101. package/dist/voice-types.js +16 -0
  102. package/dist/voice.d.ts +365 -0
  103. package/dist/voice.js +807 -0
  104. package/dist/webhooks.d.ts +87 -0
  105. package/dist/webhooks.js +136 -0
  106. package/examples/existing-browser.mjs +62 -0
  107. package/examples/preflight/classify-direct.mjs +21 -0
  108. package/examples/preflight/classify-kernel.mjs +30 -0
  109. package/examples/preflight/inspect-browser.mjs +44 -0
  110. package/examples/preflight/kernel-native/README.md +112 -0
  111. package/examples/preflight/kernel-native/documented-adapters.json +113 -0
  112. package/examples/preflight/kernel-native/inventory.json +242 -0
  113. package/examples/preflight/kernel-native/qualification.mjs +182 -0
  114. package/examples/preflight/kernel-profile.empty.json +11 -0
  115. package/examples/preflight/mollie-hosted.observations.json +23 -0
  116. package/examples/preflight/mollie-hosted.result.json +103 -0
  117. package/examples/preflight/stripe-script.direct.result.json +92 -0
  118. package/examples/preflight/stripe-script.observations.json +16 -0
  119. package/examples/preflight/stripe-script.result.json +87 -0
  120. package/package.json +53 -0
  121. package/readme.test.mjs +53 -0
package/README.md ADDED
@@ -0,0 +1,864 @@
1
+ # @agent-cards/sdk
2
+
3
+ Agentcard's SDK: one client, `Agentcard`, with two modules. The directory in the monorepo is still `packages/checkout`; the package on npm is `@agent-cards/sdk`. `@agent-cards/checkout` is its older name and re-exports this package, entry for entry. `@agent-cards/voice` is the phone package, not an alias of this one.
4
+
5
+ | Module | What it does |
6
+ |---|---|
7
+ | `agentcard.checkout` | Pauses a browser purchase's card request, has the cardholder approve it, and replays the processor's answer; creates a purchase directly; the recognisers and the request builders |
8
+ | `agentcard.voice` | Connects a person's card to your product once; approves a purchase by voice inside it: the key word, the stream, the result; reads a returning person; revokes a connection |
9
+
10
+ Card data never enters your process, your logs or your network: your server holds a placeholder card and a token, and the person's device supplies the real card.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i @agent-cards/sdk
16
+ ```
17
+
18
+ Node 22 or any runtime with `fetch`, `WebSocket` and WebCrypto. Zero runtime dependencies. One base URL, `https://api.agentcard.sh`; sandbox and live are decided by your credentials.
19
+
20
+ ## Hello world
21
+
22
+ A person connects their card to your app once, then approves a purchase by voice. Six lines, against a sandbox client.
23
+
24
+ ```js
25
+ import { Agentcard } from '@agent-cards/sdk';
26
+
27
+ const agentcard = new Agentcard({ clientId: process.env.AGENTCARD_CLIENT_ID, clientSecret: process.env.AGENTCARD_CLIENT_SECRET, voice: { approval: 'sheet', returnUrl: 'yourapp://connected' } });
28
+
29
+ const link = await agentcard.voice.connect(); // the app opens link.url in its sheet
30
+ const person = await agentcard.voice.completeConnect(returnLink); // the link the sheet came back with
31
+ const purchase = await agentcard.voice.authorizations.create({ user_id: person.user_id, merchant: 'Coffee', amount: 1250, currency: 'usd', request: 'sandbox' });
32
+ const { card, cards } = purchase; // the card that pays and why; cards are the others, for authorizations.setCard(purchase.id, id)
33
+ const result = await agentcard.voice.awaitResult(purchase.id); // { status: 'approved', approved_with: 'voice' }
34
+ ```
35
+
36
+ The app opens `purchase.approval_url` in its sheet for the approval (see `@agent-cards/voice`). Sandbox and live are decided by the credentials: the token exchange names the client's mode and the client reads it (`agentcard.sandbox`), so nothing here says which one this is. `request: 'sandbox'` is the first-run form: it names no processor, the sandbox completes it as a sandbox charge, and only a sandbox client may send it. At go-live the credentials change and the request becomes the processor's own card request: one a browser purchase paused on, or one `agentcard.checkout.requests.build(...)` made for your order. `voice.returnUrl` is exactly one of the redirect links registered on your client. `readme.test.mjs` runs this block against a stub on every test run.
37
+
38
+ ### A returning person
39
+
40
+ Store `person.user_id`. On a later launch ask what they already have, and open the Vault only for what is missing.
41
+
42
+ ```js
43
+ const who = await agentcard.voice.people.get(storedUserId); // { user_id, card, voice: ['en'], rule, passkey }
44
+ if (!who.card || !who.voice.includes('en')) {
45
+ const link = await agentcard.voice.connect({ voice: 'required' }); // the Vault shows only the missing step
46
+ }
47
+ ```
48
+
49
+ `connection_not_found` means the person never connected through your client (or disconnected): treat them as new.
50
+
51
+ ### Approving inside your app
52
+
53
+ `voice: { approval: 'sheet' }`: the person approves on Agentcard's page, opened in your app for one moment; `purchase.approval_url` (or `voice.approvals.approvalUrl(id)`) is the link. `voice: { approval: 'in_app' }`: hold-to-talk in your app, judged on the phone and by Agentcard, signed by a key in your app. The server then mints the key word as one sealed bundle the app forwards unread:
54
+
55
+ ```js
56
+ const bundle = await agentcard.voice.challengeForHost(purchase.id, { lang: 'es' }); // to the app's VoiceHost.approve, unread
57
+ const reg = await agentcard.voice.appKeys.register(key, { user_id: person.user_id }); // the key the app's frame minted; reg.credential goes back to the phone
58
+ ```
59
+
60
+ One choice per business, not per purchase; there is no default, and a client built without `voice` has every `voice.*` verb throw `missing_approval_posture`.
61
+
62
+ ### The card that pays
63
+
64
+ Under the person's smart routing (on unless they turned it off in the Vault) the card that earns the most for the purchase pays when your create pins none, and every read names it: `purchase.card` is `{ id, name, last4, brand, reason }`, the reason the recommendation's sentence for it, null when your create pinned the card (`card_id`) or the person chose another. `purchase.cards` lists every card the person could pay with instead, each with `recommended` and the recommendation's reason where it scored the card. The key word line names the card and the reason before the word ("Paying 34 dollars and 99 cents to Epicdrops with your Sapphire, three times the points on shopping. Please say your secure passphrase, …"), and the bundle carries `card` so the app shows it beside the word: hearing the card, then speaking the passphrase, is the confirmation. "Another card", spoken or tapped, is `voice.authorizations.setCard(purchase.id, card.id)` before the approval; ask for the key word after it, so the line names the card that will pay. Once the purchase is decided the call answers `already_finalized`.
65
+
66
+ ### Where the key word goes
67
+
68
+ `challengeForHost(id, { delivery })` names where the key word goes, and the bundle carries both of Agentcard's lines either way: `say` with the word in it, `say_private` pointing at the screen. `delivery: 'screen'`: the app shows `word`, speaks `say_private`, and the word never leaves the phone as sound. `delivery: 'spoken'`: the app speaks `say`, or plays `audio_url` (`audio_delivery` says which form it carries), so a purchase completes with no look at the screen; the word stays on the screen as well. `voice.challenge` takes the same option.
69
+
70
+ The choice is yours, and it has a cost. Spoken through the loudspeaker, a nearby recorder hears the fresh word before the person has said it. Over headphones or the earpiece it stays with them. The app half reads the audio route (this package cannot) and names it on the streamed answer as `channel`; the `@agent-cards/voice` README says how the reference app reads it.
71
+
72
+ ### A bot with no screen
73
+
74
+ A messaging bot or an agent with no screen in front of the person sends `link.url` or `purchase.approval_url` as a text itself; the person opens it on their phone. Leave `return_url` out on such a connect: the Vault ends on "Connected", the link polls (`grant: 'poll'`), and `voice.awaitConnected(link.session_id)` reads the result.
75
+
76
+ ### Languages
77
+
78
+ A language is a BCP 47 tag, and the list Agentcard speaks is data: `await agentcard.voice.languages()` reads it (`[{ tag: 'en', name: 'English' }, { tag: 'es', name: 'Español' }]`), and every call that names a `lang` checks the tag against it first, refusing one not on the list with `unsupported_language` before anything is sent. A language added on Agentcard's side needs no new release of this package.
79
+
80
+ ### Reading, without waiting
81
+
82
+ `voice.awaitResult`, `voice.awaitConnected` and `voice.actions.awaitDone` wait. For one read with no wait, from a handler that answers a poll: `voice.authorizations.get(id)`, `voice.sessions.get(session_id)`, `voice.actions.get(session_id)`. Every read waits out the API's `slow_down` for you. Passing `timeout_ms: 0` to an await verb is not a read; use the read verbs.
83
+
84
+ ### Webhooks
85
+
86
+ Agentcard signs every delivery. Verify against the raw bytes and branch on `type`:
87
+
88
+ ```js
89
+ import { verifyWebhook, isCheckoutAuthorizationEvent } from '@agent-cards/sdk';
90
+
91
+ const event = await verifyWebhook(rawBody, req.headers['agentcard-signature'], [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_PREVIOUS]);
92
+ if (isCheckoutAuthorizationEvent(event) && event.type === 'checkout_authorization.approved') markPaid(event.data.id);
93
+ ```
94
+
95
+ More than one secret is accepted, so a secret rotates without a missed event; a stale timestamp, a malformed header and a wrong signature each throw an `AgentcardError` with its own code. The event unions are `CheckoutAuthorizationEvent` (`checkout_authorization.*`) and `VaultEvent` (`vault.*`); an event of a type this release does not name arrives as `OtherEvent`, so branch on `type` and read `data` yourself.
96
+
97
+ ## Complete a purchase you already approved
98
+
99
+ `agentcard.buy` is Agentcard's Purchase API from the same client: it finds the merchant, builds the cart and places the order for a person you connected. When the person already approved the purchase by voice, the confirm names that approval, and the API places against it and asks them for nothing a second time. Same `agentcard` and `user_id` as the hello world.
100
+
101
+ ```js
102
+ const purchase = await agentcard.voice.authorizations.create({ user_id, merchant: 'Coffee', amount: 650, currency: 'usd', request: 'sandbox' });
103
+ const result = await agentcard.voice.awaitResult(purchase.id); // { status: 'approved', approved_with: 'voice' }
104
+ const turn = await agentcard.buy.ask({ user_id, ask: 'a flat white from Coffee to my desk' }); // { status: 'needs_input', cart: { hash, totalCents, items } }
105
+ const placed = await agentcard.buy.confirm({ user_id, conversation_id: turn.conversation_id, confirm: turn.cart.hash, authorization_id: purchase.id });
106
+ const state = await agentcard.buy.conversation({ user_id, conversation_id: turn.conversation_id }); // { turn_in_progress: false, orders: [{ order_id }] }
107
+ if (placed.status !== 'order_placed') console.log(placed.status, placed.decline_code); // 'declined' names why; 'needs_input' is a question, not a failure
108
+ ```
109
+
110
+ Every call names `user_id`; a person not connected to your client is refused with `user_not_connected`. `buy.ask` answers one turn: `status` is `needs_input` (a question, or a cart waiting for its confirm), `order_placed`, `partially_placed` (several carts, some placed; each one's outcome is in `placements`) or `declined` (with `decline_code`). Read `turn.cart.hash` and never the prose; a cart that changed since its hash was issued refuses the confirm with `cart_changed`, and the fresh carts ride on the error. `buy.confirm` with `authorization_id` places against the approval the person gave: one that is not approved, is for another purchase, or already paid an order is refused with `authorization_not_approved`, `authorization_mismatch` or `authorization_spent`, and nothing is charged; the answer carries `authorization_id` back. Without it the confirm runs as before, with `payment_source: 'vault'` for the person's own card. A turn runs against live merchants, so this call waits up to 130 seconds and is sent once, never retried; a turn still running past the API's own budget answers `error_code: 'turn_timeout'` with the carts it had shown, and `buy.conversation` reads whether it is done (`turn_in_progress`) and every order it placed. Refusals are a `BuyError`, an `AgentcardError` with the API's code; `isBuyDeclined(turn)` narrows a declined answer. `buy.test.mjs` runs this block against a stub on every test run.
111
+
112
+ ## Approve a purchase by voice inside your app
113
+
114
+ When your app holds the person, the confirm asks for their approval by voice instead of an approval page: `approval: 'voice'` with `payment_source: 'vault'`. The API mints the purchase's own approval and answers `status: 'needs_approval'` with its `authorization_id`; your server mints the key word on that id with `voice.challengeForHost`, exactly as for a purchase you created yourself, the app runs it, and the same confirm sent again places the order. `buy.approveByVoice` does the whole dance: one confirm, your `challenge` on the id, one retry. `app.approve(bundle)` below stands for your app taking the bundle to `VoiceHost.approve`.
115
+
116
+ ```js
117
+ const turn = await agentcard.buy.ask({ user_id, ask: 'a flat white from Coffee to my desk' }); // { status: 'needs_input', cart: { hash } }
118
+ const placed = await agentcard.buy.approveByVoice({ user_id, conversation_id: turn.conversation_id, confirm: turn.cart.hash,
119
+ challenge: async (id) => { await app.approve(await agentcard.voice.challengeForHost(id)); await agentcard.voice.awaitResult(id); },
120
+ }); // 'order_placed', 'declined', or 'voice_execution_unsupported'
121
+ if (placed.status === 'declined') console.log(placed.decline_code); // 'voice_declined' | 'approval_expired'
122
+ if (placed.status === 'voice_execution_unsupported') console.log(placed.error.message); // take your own path; nothing was charged
123
+ ```
124
+
125
+ `challenge` is called once, with the `authorization_id` the API minted and the `needs_approval` turn it rode on; resolve it when the verdict is in (`voice.awaitResult`, or your app's own word), and the retry follows. The answer is the final turn: `order_placed` with `order_id` and the `authorization_id` it was placed against; `declined` with `decline_code: 'voice_declined'` when the person said no, or `'approval_expired'` when the approval ran out first; `needs_approval` again when the verdict was still out, in which case a later `buy.confirm` with the same hash and `approval: 'voice'` completes it. A store whose connector cannot place under a voice verdict answers `409 voice_execution_unsupported`; `approveByVoice` returns it as `status: 'voice_execution_unsupported'` with the `BuyError` in `error`, and `buy.confirm` throws it, so your fallback is a branch and not a catch. A challenge that rejects is thrown as it came and nothing is placed. `isBuyNeedsApproval(turn)` and `isBuyVoiceUnsupported(turn)` narrow the two new shapes. Without `approval` the confirm is what it was: the page on `approval_url`.
126
+
127
+ ## Browser purchases
128
+
129
+ Your agent drives checkout normally. When the page tries to send the card, the SDK pauses that one request, the cardholder approves on their device, the device supplies the card and calls the processor, and the page gets the answer to replay.
130
+
131
+ ```
132
+ your agent ──drives──> merchant checkout
133
+ │ card request
134
+ ▼
135
+ [ paused by this SDK ]
136
+ │ template only, placeholder card
137
+ ▼
138
+ Agentcard ──notify──> cardholder's device
139
+ │ decrypts card locally
140
+ ▼
141
+ processor's card vault
142
+ ┌────────token────────┘
143
+ ▼
144
+ [ request resumed ] ──> order completes
145
+ ```
146
+
147
+ ```ts
148
+ const agentcard = new Agentcard({ clientId, clientSecret });
149
+ await agentcard.checkout.syncRegistry(); // the API's current recognisers; a failed read keeps the built-ins
150
+ await agentcard.checkout.attachToCdp(cdp, pageSessionId, {
151
+ user: 'usr_123', // whose card should pay
152
+ merchant: 'vanman.shop',
153
+ amount: 583, // your hint, an integer in the currency's smallest unit (or a decimal string: '5.83')
154
+ currency: 'usd',
155
+ onApprovalUrl: (url) => sendToUser(url), // SMS, push, email, iMessage: your call
156
+ });
157
+ ```
158
+
159
+ Playwright: `agentcard.checkout.attachToPlaywright(page, { user, merchant, amount, currency })`. The named exports `attachToCdp(cdp, id, { vault: agentcard, … })` and `attachToPlaywright(page, { vault: agentcard, … })` are the same calls.
160
+
161
+ `amount` is your hint. The processor's own amount is the higher authority: Agentcard reads it from the paused request or from the Stripe intent the request names, right before the cardholder's device replays, and the company's caps are judged on it. A hint more than one smallest unit away from the processor's amount is refused with nothing charged (`AmountMismatchError`), and after the replay the charge is reconciled against the approval (`ReplayResponse.amountVerified`, `chargedAmount`, `chargedKind`, plus the `checkout_authorization.amount_mismatch` webhook when the charge disagreed). Every result carries `amountAuthority`: `processor`, `agent`, `page`, or `none`.
162
+
163
+ Merchant timeouts still apply to the request itself: Square's observed tokenization deadline is about 10 seconds, Braintree's native request timeout is 60 seconds, and Adyen Web's own request timeout abandons its Sessions payment call 60 seconds after Pay. These are the processors' limits; the SDK's own authorization wait stays 15 minutes. The approval outlives the page's own request: when the page's script gives up on its paused card request while the cardholder is still deciding, the SDK keeps the approval pending (state `awaiting_approval`, reason `merchant_request_lost`) and answers the page's next request for the same purchase from it; when the approval lands first, the controller reads `ready_to_submit` with reason `awaiting_merchant_retry`, and one more Pay click completes the purchase. The wait for that request is bounded (`merchantRetryWaitMs`, two minutes by default); an approval the page never asks for again is retired as `merchant_never_retried`. For a short-deadline processor, `controller.prepare()` before the first Pay finishes the request inside its own deadline.
164
+
165
+ ### Pause only, and let your server create
166
+
167
+ `attachToCdp(…, { pauseOnly: true, onPaused })` pauses the card request without creating the purchase and hands you `{ request, recognizer, page_origin, controller }`. Create it with `voice.authorizations.create` as on any other purchase, read the result, and `controller.resume(result)` replays the processor's answer into the page. `recognizer` names the processor that claimed the request (`stripe`), and so does the `card_request_paused` event's detail, so nothing has to re-match the URL.
168
+
169
+ ### Recognisers and request builders
170
+
171
+ Both are registries the SDK ships and you extend at runtime, so a processor or a store Agentcard does not know yet needs no release.
172
+
173
+ ```js
174
+ agentcard.checkout.recognizers.list(); // the API's list (after syncRegistry) or the built-ins, plus yours
175
+ agentcard.checkout.recognizers.register({ psp: 'acme', match: /pay\.acme\.test\/tokens/i, hosts: ['^pay\\.acme\\.test$'], encoding: 'json' });
176
+ agentcard.checkout.recognizers.find('https://pay.acme.test/tokens'); // { psp: 'acme', … } | null
177
+
178
+ agentcard.checkout.requests.list(); // stripe/payment_intent_confirm, stripe/card_token, stripe/payment_method, plus yours
179
+ agentcard.checkout.requests.build({ processor: 'stripe', kind: 'payment_intent_confirm', origin: 'https://yourapp.example', intent: { id, client_secret, publishable_key } });
180
+ agentcard.checkout.requests.register({ processor: 'acme', kind: 'pay', build: ({ order }) => ({ url, method: 'POST', headers, body }) });
181
+ ```
182
+
183
+ The three shipped builders are the Stripe shapes Agentcard completes from a direct create today: confirming a PaymentIntent your server created, tokenizing a card for your server to charge, and creating a PaymentMethod for your server to confirm with. A Braintree or Mercado Pago purchase pauses in a browser (`attachToCdp`) or runs as a prepared checkout (`checkout.prepareCheckout`); neither has a direct-create builder yet. `stripePaymentIntentConfirm(origin, intent)` stays as a named export for one release.
184
+
185
+ ### Moving from `@agent-cards/checkout` 0.x
186
+
187
+ `@agent-cards/checkout` is this package's older name and re-exports it for one release. `@agent-cards/voice` is the phone package, not an alias of this one. `VaultClient` constructs the same client as `Agentcard`; every top-level checkout verb (`syncRegistry`, `authorize`, `isCardRequest`, …) is also under `agentcard.checkout`. The person's connection verbs (`connect`, `completeConnect`, `exchange`, `awaitConnected`, `disconnect`, `sessions.get`, `people.get`) are the voice module's. `voice.authorizations.create` and `voice.appKeys.register` now require `user_id`: there is no default from an earlier connect. `lang` is a `Language` tag (a string), not a union. The phone package is `@agent-cards/voice`.
188
+
189
+ ## The paused request
190
+
191
+ A purchase's `status` is one of `awaiting_approval`, `approved`, `declined` (the person said no, a rule or the processor refused: `terminal_reason` says which), `cancelled` (withdrawn before a decision, by you or by the checkout runtime), `expired`, or, from `awaitResult` alone, `timeout` at your deadline. `voice.authorizations.cancel(id, { reason })` withdraws a purchase the person has not decided yet, in your words (`person_closed_sheet`, `person_changed_mind`, `business_cancelled`). `checkout_origin` on the create (or the paused request's `origin` header) is what an auto-approval rule needs to take the purchase; without it the create answers `execution_reason: 'unverified_origin'`. On a finished purchase `awaitResult` and `authorizations.get` carry `settlement`: whether the charge settled, as Agentcard read it back from the processor (`status: 'settled' | 'not_settled' | 'unknown'`, `final`, `settled_amount`, `message`).
192
+
193
+ The connect link is an OAuth authorization grant: `voice.connect` mints `state` and a PKCE challenge, the sheet's return link carries `code` and `state`, and `voice.completeConnect(url)` exchanges it once, within 60 seconds, refusing a `state` it did not mint before any call. When another server instance minted the link, pass the `code_verifier` and `expected_state` that `connect` returned there. A mint with no return link polls (`grant: 'poll'`, then `awaitConnected`). `readReturnLink(url)` reads a return link's `code`, `state` and `voice_reset`. `voice.connect({ landing: 'voice_options' })` mints a link that lands the signed-in person on the Vault's Voice options page (the voice key, its reset, the language, the passkey it rests on) for an app's own settings row; it collects nothing and polls.
194
+
195
+ ## Credentials
196
+
197
+ Use your **OAuth client credentials**, not an `sk_` API key: those are retired,
198
+ and the checkout endpoints reject them (`client_credentials_required`) because an
199
+ authorization is bound to the confidential client that created it. The SDK does
200
+ the `client_credentials` exchange for you, caches the token, and refreshes it
201
+ once on a 401. Create a client from the dashboard Credentials page or with
202
+ `agent-cards-admin oauth-clients create`.
203
+
204
+ ## Why you need the SDK and not just `Fetch.enable`
205
+
206
+ Card fields render in **cross-origin iframes**, which are separate CDP targets.
207
+ Enabling `Fetch` on the page session never sees the tokenization request. You
208
+ need recursive `Target.setAutoAttach({ flatten: true })` on every nested target,
209
+ then `Fetch.enable` on each, then `Runtime.runIfWaitingForDebugger` to unpause
210
+ them. That, plus which headers a merchant requires you to replay verbatim, is
211
+ what this package encapsulates.
212
+
213
+ ## What runs where
214
+
215
+ | | Sees the real card |
216
+ |---|---|
217
+ | Your agent / browser | **no**, only a dummy PAN and a token |
218
+ | Agentcard servers | **no**, a request template and a token |
219
+ | Cardholder's device | yes, decrypts locally, calls the merchant directly |
220
+
221
+ Because your process only ever handles a dummy card and an opaque token, this
222
+ integration is designed to keep you out of PCI scope. Get your own QSA's read
223
+ before you put that in writing.
224
+
225
+ ## Supported processors
226
+
227
+ Coverage is specific to the processor request format, merchant setup, browser transport and follow-up flow. A recognized endpoint is not proof that every store using that processor completes checkout.
228
+
229
+ | Processor | Status |
230
+ |---|---|
231
+ | Shopify | supported, verified end to end |
232
+ | Stripe | tokenization replay and direct card-bearing PaymentIntent confirms are implemented; direct confirms read the intent's amount back from Stripe; a hint sent as `amount` + `currency` must agree with it. Browser token-to-intent continuation is unsupported and held. Validate the exact merchant flow before pilot use |
233
+ | Braintree card tokenization | Prepared checkout supported; one live Haymarket Books ebook purchase with SDK `0.5.0` confirmed merchant fulfillment and SDK `completed` using a merchant receipt resolver. Independent processor capture/settlement, live 3DS and PayPal wallet flows remain unverified. |
234
+ | Checkout.com | supported |
235
+ | Mercado Pago | Card tokenization and prepared checkout are implemented. Guest Checkout Pro in Mexico also corrects the issuer for one native card association when the selected card has the same brand and type. One live MXN 40 Lotería Chida purchase with published SDK 0.10.0 completed automatically through Pay; the merchant confirmed paid status and PDF fulfillment. The historical SDK result remains unknown because the private receipt adapter rejected a relative download URL; a separate read with the corrected adapter confirms that same paid receipt. Independent processor capture/settlement and other country or integration paths remain unverified. |
236
+ | VGS Collect (Very Good Security; Wolt) | not supported: VGS's proxy aliases only submissions from its own iframe, so a replay from the cardholder's device is refused by the merchant (verified on Wolt, 2026-09-03). Not recognized, so the agent's browser is not paused there |
237
+ | Adyen | supported (mode `cse`): the vault encrypts the card for Adyen on the cardholder's device and your browser sends it. Sessions flow: the paused request is `/checkoutshopper/v1/sessions/{id}/payments` on Adyen's own hosts. A merchant that posts the encrypted fields to its own server pauses only when Agentcard reviewed it as a merchant profile (Dunelm and Cinemark so far); both profiles are in `observe`, so their card requests are reported and aborted and nothing pays there yet (see "Adyen merchants that take the card on their own server"). Without a preparation, approval starts at Pay and must land before Adyen Web's own request timeout (observed at 60 seconds on Adyen Web 6.41 and 6.44; not enforced by the SDK); `prepare({ psp: 'adyen' })` moves the approval before Pay |
238
+ | Tranzila | supported (mode `hosted_form`): the cardholder finishes on Tranzila's own page; the paused form navigation resolves to a synthetic page, and you poll the merchant's order state |
239
+ | Fiserv Commerce Hub | implemented (mode `cse`, prepared checkout only) and off until Agentcard turns it on for your company: the card frame's card capture on `connect.fiservapis.com` (or `connect-cert.fiservapis.com` in the sandbox), with the approved card encrypted on the cardholder's device under a key Agentcard reviewed for the merchant. A merchant whose frame posts to Fiserv's `payment-link` endpoint is not recognized. No live merchant purchase is verified yet |
240
+ | Gravy (Gr4vy) Secure Fields | not supported: recognized, not intercepted, and the preflight reports `unsupported` with `request_method_not_supported`. The card request is Secure Fields' update of a checkout session: a JSON body to `/checkout/sessions/<session id>/fields` on the merchant's own host `api.<merchant>.gr4vy.app` (sandbox: `api.sandbox.<merchant>.gr4vy.app`), carrying `payment_method.number`, `payment_method.expiration_date` as one `MM/YY` value and `payment_method.security_code`. Gravy publishes this request as an HTTP PUT and the SDK pauses and replays POST requests only, so today the SDK recognizes the destination but the request continues to Gravy untouched. The shape follows Gravy's published Secure Fields contract and is not confirmed by a capture from a live merchant form. Updating the session stores the card with Gravy; the merchant creates the transaction from the session on its own server, so a completed request is not a payment. Gravy Embed, the hosted frame on `embed.<merchant>.gr4vy.app`, is not covered |
241
+
242
+ The recognizer list, and the status of each reviewed Adyen merchant profile on
243
+ your client, are fetched from the API at runtime (`agentcard.checkout.syncRegistry()`),
244
+ so new processors work without you shipping a release. `attachToCdp` derives the
245
+ `Fetch.enable` url patterns from that same list rather than a constant, which is
246
+ why the sync call belongs before the attach. `agentcard.checkout.cardUrlPatterns()` returns
247
+ those patterns if you arm a CDP connection yourself. Call
248
+ `GET /v2/checkout/recognizers?modes=token,cse,hosted_form&capabilities=fiserv_card_capture`
249
+ for the list this build syncs. The API serves Fiserv's card capture only to a
250
+ build that lists `fiserv_card_capture`, and only for a company Agentcard turned
251
+ Fiserv on for. An older SDK never pauses a Fiserv request it could not finish,
252
+ and no SDK pauses one for a company Fiserv is off for.
253
+
254
+ ## Modes
255
+
256
+ Each recognizer carries a `mode` (absent means `token`), and every authorization
257
+ carries the mode it was handled in. The adapters do the right thing for all
258
+ three; the difference matters if you drive `authorize()` yourself.
259
+ `ReplayResponse` is a union, so branch on `mode`.
260
+
261
+ - **`token`** (every processor but Adyen and Fiserv). The cardholder's device calls the
262
+ processor and reports its answer; `authorize()` resolves with `status`,
263
+ `headers` and `body` to fulfill the paused request with. The browser checks
264
+ a fulfilled answer exactly as it checks a real one, so when the page called
265
+ the processor cross-origin (Stripe always does: Checkout on
266
+ `checkout.stripe.com` and Elements in the `js.stripe.com` frame both fetch
267
+ `api.stripe.com`) the answer must carry `access-control-allow-origin` for
268
+ the request's own `Origin`, or the page's fetch rejects and the checkout
269
+ reports a connection error even though the cardholder approved. The
270
+ adapters add those headers (`corsHeadersFor` + `withCorsHeaders`, exported
271
+ for a runtime that fulfills by hand, and `corsDecision` when you also want
272
+ the reason) and report the decision on the `authorized` event as `cors`:
273
+ `echoed`, `same_origin`, or `none` (no usable Origin on the request, so
274
+ the page could not read the answer). Only what a browser serializes is
275
+ echoed: one canonical http(s) origin, or the opaque `null`. Shopify's
276
+ card iframe posts to its own origin, so it never needed them.
277
+ - **`cse`** (Adyen and Fiserv). Adyen's own page SDK encrypts the card before the request
278
+ leaves the browser, so the paused body carries ciphertext. The cardholder's
279
+ device produces the same ciphertext under the merchant's Adyen public key
280
+ (fetched by Agentcard from Adyen's host when the request is parked) and
281
+ `authorize()` resolves with `substitutions: { encoding: 'json', at, fields,
282
+ remove }`. Write them into the paused body with
283
+ `substituteEncryptedFields(body, substitutions)` and CONTINUE the request
284
+ from the same browser (`Fetch.continueRequest` with the rewritten
285
+ `postData`, or Playwright's `route.continue({ postData })`): its session
286
+ data, risk data and cookies must stay its own. Only the four encrypted
287
+ fields change, and the siblings named in `remove` are dropped (Adyen's
288
+ `brand`, which adyen-web derived from the dummy digits the agent typed:
289
+ left in place it names the wrong card and Adyen refuses the mismatch;
290
+ absent, Adyen reads the brand off the card it decrypts). A body that lacks
291
+ the fields throws `SubstitutionError`, which is not terminal. Adyen answers the browser,
292
+ so `charged_kind` is null on the approval and the merchant's order state is
293
+ the outcome to poll. A cse replay with `kind: 'merchant_hosted'` is an Adyen
294
+ merchant's own endpoint instead: its `substitutions` name the profile, a JSON
295
+ path (`[]` is the body root) and the merchant's field names, and
296
+ `substituteMerchantHostedBody(body, replay)` writes it after checking the body
297
+ against `bodySha256` (see "Adyen merchants that take the card on their own
298
+ server"). A Fiserv card capture (a prepared checkout only) comes back with its
299
+ envelope's four members at `source.encryptionData`: write them with
300
+ `substituteFiservEnvelope(body, substitutions)`, never
301
+ `substituteEncryptedFields`, and continue the request the same way.
302
+ `substituteFiservEnvelope` throws, writing nothing, when the body is not the
303
+ paused capture or the envelope names another key than the capture does. The
304
+ API serves a Fiserv envelope only in the moments after the approval; a later
305
+ read raises `PaymentOutcomeUnknownError` with reason `cse_substitutions_expired`.
306
+ - **`hosted_form`** (Tranzila). The processor's hosted card form submits the
307
+ card as a TOP-LEVEL form post, so the paused request is a page navigation
308
+ (the adapters arm `Fetch.enable` with no resource-type filter and attach the
309
+ processor's iframe, which is how a Document request on `direct.tranzila.com`
310
+ gets paused at all). The cardholder's device rebuilds that form with the
311
+ real card and submits it itself; the processor answers the device, and
312
+ `authorize()` resolves with `{ mode: 'hosted_form', kind:
313
+ 'submitted_on_device', outcome: 'unverified', submittedAt }` once the
314
+ device reports the form left. **This is not an approved payment.** The
315
+ stamp is the cardholder's device attesting that the form left it;
316
+ Agentcard holds no processor evidence on this mode and cannot obtain any,
317
+ so the API finishes the authorization as `submitted_on_device` (never
318
+ `approved`) and sends your server `checkout_authorization.submitted`
319
+ (never `.approved`). Treat it as "the person paid, or tried to, on their
320
+ own device" and confirm the order with the merchant before you count it.
321
+ There is no response to replay: FULFIL the paused navigation with
322
+ `hostedFormSubmittedPage({ authorizationId, merchant, submittedAt })` (200,
323
+ `text/html`, `x-agentcard-checkout: submitted_on_device`, a `<meta
324
+ name="agentcard-checkout">` and an inert JSON block saying "submitted on
325
+ the cardholder's device, payment unverified, do not resubmit"), the way
326
+ the adapters do. Do not abort it: an aborted navigation renders nothing,
327
+ the iframe silently keeps its dummy-card form, and the agent's next move is
328
+ to click Pay again. Do not fake the processor's result page either: this
329
+ SDK does not know the outcome. The adapters emit `submitted_on_device` (not
330
+ `authorized`), refuse a byte-identical re-post of the same form for 15
331
+ minutes (`hostedFormRepeatQuietMs`), and the API answers a regenerated one
332
+ with `409 duplicate_submission`, which the adapters quiet the way they quiet
333
+ a decline (`approvalCooldownMs`): the page's immediate re-posts are refused
334
+ without a round trip, and once the prior authorization is declined or
335
+ expired the same form is a new question. Confirm the order with the
336
+ merchant, which learns the outcome from the processor.
337
+
338
+ `syncRegistry()` asks the API for `SUPPORTED_MODES` only
339
+ (`token,cse,hosted_form`), so a processor whose flow this build cannot finish
340
+ is never paused; the API serves `hosted_form` entries only to callers that ask.
341
+ A registry mode this SDK cannot finish throws `UnsupportedModeError` before
342
+ creation. An approval returned in an unexpected mode has an unknown outcome
343
+ and holds the attachment for reconciliation. The `authorized` event's detail names
344
+ the `mode`, the `authorizationId` and, for `cse`, the `fields` that were
345
+ substituted; it never carries ciphertext. The `submitted_on_device` event's
346
+ detail names the `authorizationId`, `submittedAt` and `outcome:
347
+ 'unverified'`; it is not an `authorized` event and must not be counted as
348
+ one. `amountAuthority` on every replay is `stripe_payment_intent`,
349
+ `hosted_form_sum` (the form's own amount) or `display_only`.
350
+
351
+ ## Errors worth handling
352
+
353
+ - `ApprovalTimeoutError`: the server confirms the authorization expired without a replay attempt. A local deadline is different: `PaymentOutcomeUnknownError` means the approval link may still be valid, so reconcile the merchant order before another attempt.
354
+ - `ApprovalDeclinedError`: the user said no.
355
+ - `AmountMismatchError`: the processor's amount did not match the amount the
356
+ user was (or would have been) asked to approve. Nothing was charged. An
357
+ `ApprovalDeclinedError` with `expectedCents`, `actualCents`, `currency`,
358
+ `code: 'amount_mismatch'` and `stage`: `'pre_replay'` (checked right before
359
+ the device would have sent the card; `authorizationId` names the declined
360
+ authorization) or `'create'` (the intent already disagreed when the request
361
+ was parked; no authorization exists, `authorizationId` is null). Per
362
+ request, not per page: a merchant can still update an intent's amount
363
+ until it is confirmed, so the adapters quiet the page's immediate retry
364
+ and judge the next request afresh instead of latching.
365
+ - `IntentNotConfirmableError`: the PaymentIntent was already charged, is
366
+ processing, or is authorized and on hold (or canceled), so Agentcard
367
+ refused to replay a confirm at it. An `ApprovalDeclinedError` with
368
+ `code: 'intent_not_confirmable'`. Deliberately not "nothing was charged":
369
+ check the intent at Stripe before retrying.
370
+ - `ProcessorRefusedError`: the cardholder's device reported a processor
371
+ request rejection. `pspErrorCode` carries the processor's code; optional
372
+ `processorError` carries bounded Razorpay reason, source, step and payment/order
373
+ identifiers when the API has them. A generic code such as `BAD_REQUEST_ERROR`
374
+ does not establish an issuer decline or prove no money moved. Reconcile the
375
+ merchant payment before retrying. This remains an `ApprovalDeclinedError`
376
+ with `code: 'processor_refused'` for compatibility.
377
+ The attachment records `status: 'declined', reason: 'processor_refused'`
378
+ and holds further card requests. After confirming merchant failure, call
379
+ `retryAfterMerchantFailure({ status: 'failed' })` to permit a deliberate new
380
+ attempt immediately, without waiting for the user-decline cooldown. Do not
381
+ automatically create a new attachment after this error;
382
+ the guard applies only within the existing attachment.
383
+ - `CheckoutApiError` with `code === 'amount_unverifiable'`: Stripe could not
384
+ be asked (502; the SDK retries twice, 500ms then 1500ms, before throwing)
385
+ or the paused request lacked its client secret or publishable key (400).
386
+ `code === 'intent_not_confirmable'` at create (409) means the intent was
387
+ already used; the adapters stop intercepting for that page.
388
+ `code === 'cse_key_unavailable'` (502) means Adyen did not answer the
389
+ public-key fetch and is retried the same way; `cse_client_key_unknown`
390
+ (400) means Adyen does not know the merchant's `clientKey`, and
391
+ `cse_template_unsupported` (400) means the paused body carries no
392
+ encrypted card fields to fill (a stored card, a wallet, a single-blob
393
+ `encryptedCard`); both are terminal for that page.
394
+ `hosted_form_template_incomplete` (400) means the paused form lacks a
395
+ field the device fills or the processor requires, `hosted_form_gated`
396
+ (400) means it carries a live captcha token the device could never
397
+ re-submit, and `hosted_form_field_refused` (400, with `field` and a
398
+ `reason` of `stored_credential`, `not_a_sale` or `callback_host`) means
399
+ the form asks the processor for something other than one plain sale
400
+ reporting to the merchant you named (a reusable token in or out, a sale
401
+ mode that is not a sale, a callback URL off the merchant's host: name the
402
+ merchant by its hostname when the form carries callback URLs); all three
403
+ are terminal for that page. `duplicate_submission` (409,
404
+ with `prior_authorization_id` and `prior_status`) means this exact
405
+ submission already has, or already had, its prompt: the adapters quiet the
406
+ page's re-posts for `approvalCooldownMs` (no second notification for one
407
+ payment) and judge the next request afresh, since the prior authorization
408
+ declines or expires and the same form is then a new question.
409
+ - `AdyenTestPlatformRefusedError`: a payment on Adyen's TEST platform was
410
+ refused, since a test account can read whatever is encrypted under its key.
411
+ Nothing was encrypted or charged. An `ApprovalDeclinedError` with `code`
412
+ `adyen_test_environment_refused` (a live checkout names Adyen's test host or
413
+ a `test_` client key; at `stage: 'create'` nothing was created and the
414
+ adapters stop intercepting for that page) or
415
+ `adyen_test_platform_requires_documented_test_card` (a test-mode checkout
416
+ where the cardholder's card is not one of Adyen's documented test cards).
417
+ The controller reads `failed` at `stage: 'create'` and `declined` at
418
+ `stage: 'pre_replay'`, with the code as its reason, and the adapters' event
419
+ names the code (`AdyenTestPlatformRefusedError: <code>`).
420
+ A preparation refused for Adyen's test platform, or for a merchant profile
421
+ that is not turned on, not offered to your account, or whose key or checkout
422
+ page changed, throws `CheckoutPreparationError` with the API's code as its
423
+ `reason`.
424
+ - `CardEncryptedError`: this processor encrypts the card in-page and its
425
+ registry entry does not (yet) say the vault can produce that ciphertext;
426
+ route the purchase to an Agentcard-issued card instead.
427
+ - `UnsupportedModeError`: the registry requests a mode this SDK cannot finish
428
+ before an authorization exists. Upgrade. An unexpected approved mode instead
429
+ raises `PaymentOutcomeUnknownError` and requires reconciliation.
430
+ - `PreparationRequiredError`: a direct `authorize()` call for a processor that
431
+ pays only through a prepared checkout (Fiserv) passed no preparation. Nothing
432
+ was created and nothing was charged; call `prepareCheckout()` first. The
433
+ adapters refuse such a request before it pauses, with the controller reason
434
+ `preparation_required`.
435
+ - `SubstitutionError`: a `cse` approval could not be written into the paused
436
+ body (the four encrypted fields were not there). The request is failed and
437
+ the next one is judged afresh.
438
+
439
+ ## Building this package
440
+
441
+ It declares **no dependencies**, so the workspace
442
+ lockfile needs no importer entry for it (a new package with its own deps cannot
443
+ be installed here without regenerating the lockfile, and a full regen drifts
444
+ unrelated transitive versions). The voice module's streaming door is vendored
445
+ from `packages/voice/src/core/stream.ts` by `scripts/generate-voice-stream.mjs`
446
+ (`pnpm check:voice-stream` says when the copy has drifted). Build it with the
447
+ workspace TypeScript:
448
+
449
+ ```bash
450
+ cd packages/checkout && pnpm build && pnpm test
451
+ ```
452
+
453
+
454
+ ## Browser integration and merchant outcomes
455
+
456
+ `attachToPlaywright` works with an existing Chromium page reached through
457
+ `chromium.connectOverCDP`. Browserbase supplies `session.connectUrl`; Kernel
458
+ supplies `browser.cdp_ws_url`; a custom browser must expose a compatible CDP
459
+ endpoint. This is Agentcard's **direct SDK** path. Kernel's native Vault alias
460
+ integration is a separate provider adapter with its own coverage and lifecycle;
461
+ do not install both interceptors on the same checkout without validating how
462
+ those routes interact.
463
+
464
+ The local browser suite validates Chromium and nested cross-origin frames over
465
+ both Playwright routing and a raw, session-aware CDP connection. It does not
466
+ establish live Browserbase, Kernel, 3DS or merchant coverage. A raw page-scoped
467
+ Playwright `CDPSession` is not the `CdpLike` type. Raw CDP must preserve the
468
+ `sessionId` on every command/event and allow recursive target attachment.
469
+ Initial arming errors reject `attachToCdp`; a child that cannot be armed remains
470
+ paused and reports `browser_interception_unavailable` for operator recovery.
471
+
472
+ Await attachment before clicking Pay. Both adapters stop waiting for browser
473
+ setup after 30 seconds and throw `CheckoutAttachmentError` with
474
+ `code: 'checkout_attachment_failed'` and `reason: 'timeout'`, `'closed'` or
475
+ `'unavailable'`. Use `attachmentTimeoutMs` to choose a setup deadline from 1 to
476
+ 300000 milliseconds, separately from the approval's `timeoutMs`.
477
+ Close the failed checkout page and create a fresh browser context before
478
+ trying again. A late setup response cannot reopen the failed attachment or
479
+ request approval; intercepted card requests remain blocked. A setup failure
480
+ does not establish the status of any earlier purchase.
481
+
482
+ Use a checkout context created with `serviceWorkers: 'block'`. Playwright cannot
483
+ route requests intercepted by a service worker. The SDK rejects already active
484
+ service workers, but that check cannot prevent a site from registering one
485
+ later in an existing context configured to allow them. A browser extension's
486
+ own worker, at a `chrome-extension://` URL, serves no site and does not count,
487
+ so a cloud browser's default context that runs extensions (an agent, an ad
488
+ blocker, a popup blocker) attaches as it is. Attach before entering
489
+ card fields; keep the existing checkout tab. Separate popup tabs need their own
490
+ attachment. A page route does not cover a popup's first navigation; a popup
491
+ which submits payment on that navigation requires a separately validated
492
+ context/browser-level integration. Existing `page.route` handlers must call
493
+ `route.fallback()` when they do not handle a request; later routes have priority.
494
+
495
+ Both adapters now return a controller; existing code that ignores the return
496
+ value continues to work. Choose `requireMerchantResult: true` for a pilot:
497
+
498
+ ```ts
499
+ const checkout = await attachToPlaywright(page, {
500
+ vault: agentcard, user, merchant, amount, currency,
501
+ requireMerchantResult: true,
502
+ onStateChange: state => recordState(state),
503
+ onUserAction: action => deliverPrivatelyToUser(action),
504
+ resolveMerchantResult: async state => readMerchantOrder(state),
505
+ paymentEndpoints: [
506
+ { origin: 'https://payments.example.com', pathname: '/submit', methods: ['POST'] },
507
+ ],
508
+ });
509
+
510
+ // Your existing agent dispatches checkout. Later, once the paused request resumes:
511
+ const state = await checkout.reconcile();
512
+ if (state.status === 'completed') await finishAgentTask(state);
513
+ ```
514
+
515
+ `resolveMerchantResult` must verify the merchant's result for the original
516
+ payment attempt. It returns one of:
517
+
518
+ - `{ status: 'completed', orderId }`: merchant-confirmed success with a genuine order or receipt ID. Existing integrations keep this form.
519
+ - `{ status: 'completed', confirmation: { kind: 'merchant_payment', authorizationId } }`: merchant-confirmed payment when no order or receipt ID is available. The ID must match the current checkout authorization.
520
+ - `{ status: 'failed' }`: merchant confirmed the attempt failed; no successful payment/order exists.
521
+ - `{ status: 'pending' }` or `{ status: 'unknown' }`: keep waiting or reconcile; never click Pay again.
522
+ - `{ status: 'requires_user_action', reason: '3ds' | 'redirect' | 'other' }`: deliver your own browser live view or supported challenge UI to the user.
523
+
524
+ For a merchant that confirms payment without returning an order ID, your
525
+ resolver can return the explicit payment confirmation:
526
+
527
+ ```ts
528
+ const checkout = await attachToPlaywright(page, {
529
+ vault: agentcard, user, merchant, amount, currency,
530
+ requireMerchantResult: true,
531
+ resolveMerchantResult: async state => {
532
+ const payment = await readOriginalMerchantPayment(state);
533
+ if (!payment.confirmed || !state.authorizationId) return { status: 'unknown' };
534
+ return {
535
+ status: 'completed',
536
+ confirmation: {
537
+ kind: 'merchant_payment',
538
+ authorizationId: state.authorizationId,
539
+ },
540
+ };
541
+ },
542
+ });
543
+ ```
544
+
545
+ `readOriginalMerchantPayment` is your merchant-specific check. The check must
546
+ match the original payment request, amount, currency and selected card, and
547
+ verify authoritative merchant success for that attempt. HTTP 200 alone, a card
548
+ token, a success URL or text that anyone can open does not establish payment.
549
+ Copying `state.authorizationId` without checking the payment is insufficient.
550
+ The SDK checks the authorization binding; it does not independently authenticate
551
+ the merchant evidence supplied by your resolver.
552
+
553
+ Return one completion form at a time. The payment-confirmation form leaves
554
+ `state.orderId` absent and exposes `state.confirmation` with the exported
555
+ `MerchantPaymentConfirmation` type. Your consumer should finish on
556
+ `state.status === 'completed'` and treat `orderId` as optional. A missing or
557
+ mismatched authorization, or `{ status: 'completed' }` without either completion
558
+ form, leaves the outcome unknown. Confirmed completion clears stale failure or
559
+ authentication reasons and continues to block further payment submissions.
560
+ Never invent an order ID from a token or authorization ID.
561
+
562
+ The SDK does not infer order success from `authorized` or a tokenization reply,
563
+ and does not claim to detect or solve arbitrary 3DS challenges. Your merchant
564
+ resolver (or `checkout.requestUserAction('3ds')` when your browser observes it)
565
+ drives that hook. Deliver `onUserAction` approval URLs privately: they are
566
+ capabilities and never belong in general telemetry. Observer exceptions are
567
+ isolated from the payment handoff.
568
+
569
+ Native Stripe Checkout emits `checkout_blocked` before the existing `blocked`
570
+ event when its local preparation rejects a request. Its
571
+ `StripeCheckoutBlockedDetail` contains only fixed codes: `version: 1`,
572
+ `processor: 'stripe'`, endpoint family, phase, stage, reason, optional validation code, gate state, and
573
+ disposition. It contains no URLs, identifiers, request-derived field names or values, or exception text.
574
+
575
+ | Field | Values |
576
+ | --- | --- |
577
+ | `endpoint_family` | `payment_methods`, `payment_page_confirm`, `other` |
578
+ | `phase` | `tokenization`, `final`, `unknown` |
579
+ | `stage` | `request_read`, `classification`, `claim`, `readiness`, `document`, `stub_response` |
580
+ | `validation_code` | Shared-core `StripeCheckoutValidationCode`; present only for `request_validation_failed` |
581
+ | `gate_state` | `fresh`, `stubbed`, `submitted`, `stopped` |
582
+ | `disposition` | `active_claim_preserved`, `checkout_stopped` |
583
+
584
+ `reason` is the exported `StripeCheckoutBlockReason` union. It identifies SDK
585
+ claim and readiness failures, such as `duplicate_confirmation`, `document_changed`,
586
+ or `attachment_not_ready`. A shared-core rejection reports
587
+ `request_validation_failed` and a fixed `validation_code` identifying the failed
588
+ check, or `unclassified` when no recognized code is available. Initial request
589
+ classification uses phase `unknown`; a billing capture rejection uses
590
+ `tokenization`, and a billing attachment rejection uses `final`.
591
+ For example, `form_field_unknown` identifies an allowlist rejection without
592
+ revealing the field name or value. Identifying that field requires a separate
593
+ reviewed synthetic fixture; the code alone does not establish or fix a processor
594
+ schema mismatch. `gate_state` records the state before the adapter handles
595
+ the failure, though document validation may already have stopped the gate.
596
+
597
+ `active_claim_preserved` means a valid duplicate was refused without invalidating
598
+ the original request's claim. Do not cancel that checkout in response to the duplicate.
599
+ `checkout_stopped` means the local preparation was retired; reconcile any existing
600
+ authorization before another attempt. Neither disposition proves a payment outcome.
601
+
602
+ With `requireMerchantResult`, subsequent card requests stay blocked after
603
+ handoff. Stripe tokenization handoffs (`/v1/payment_methods`, `/v1/tokens`,
604
+ `/v1/sources`, `/v1/confirmation_tokens`) always hold further recognized card
605
+ requests, even when that option is false. A tokenization approval has no
606
+ authoritative binding to a specific PaymentIntent, amount or currency, and the
607
+ page cannot supply one. The SDK reports `awaiting_merchant` with reason
608
+ `stripe_tokenization_unbound`, while ordinary browser traffic stays available.
609
+
610
+ One follow-up can continue: the page's own card-free PaymentIntent confirm that
611
+ pays with the approved token (`confirmCardPayment` with `payment_method: 'pm_...'`,
612
+ `confirmPayment` with the confirmation token). The SDK asks the API, which reads
613
+ the payment from Stripe and allows it only for the approved amount and
614
+ currency, on the same Stripe account, paid with exactly the approved token.
615
+ Allowed, the request continues untouched, the SDK emits `stripe_payment_continued`
616
+ and the reason becomes `stripe_payment_continued`. One confirm continues per
617
+ approval; every other follow-up, including an unrelated intent, a changed
618
+ amount and a retry, stays blocked.
619
+ Unrecognized merchant-server endpoints remain outside this guard unless listed
620
+ in `paymentEndpoints`; this is not a guarantee against a merchant charging a
621
+ saved token on its own server.
622
+ A direct card-bearing PaymentIntent confirm remains supported with the backend's
623
+ existing amount verification when `amount` and `currency` are supplied.
624
+
625
+ Hosted-form submissions also always stay blocked because their payment outcome
626
+ is unverified. `reconcile()` calls the resolver once, coalescing concurrent calls.
627
+ After an explicit merchant-confirmed failure, the application may call
628
+ `checkout.retryAfterMerchantFailure({ status: 'failed' })` to permit another
629
+ attempt where the attachment permits recovery. This is an assertion from your
630
+ merchant integration, not a timeout or a best guess. Completed orders, cancelled
631
+ attachments and attachments that issued an unbound Stripe token cannot reset
632
+ this way, including when the token's browser delivery acknowledgement was lost.
633
+ Reconcile the merchant outcome and use a separately validated checkout flow;
634
+ do not reuse that token in a new attachment as a workaround. Configuration and
635
+ unsupported-mode failures require fixing the integration. Bank flows requiring
636
+ another confirmation and other stored-token chains remain unverified.
637
+
638
+ Worldpay, Bambora and Mercado Pago preparation requires SDK 0.6.0 or later and the matching API and Vault release.
639
+
640
+ Prepare a Square, Braintree, Worldpay, Bambora or Mercado Pago checkout before the first Pay action so the cardholder can approve before the native card request starts. The API and Vault deployments must support the selected processor:
641
+
642
+ ```ts
643
+ const checkout = await attachToPlaywright(page, {
644
+ vault: agentcard, user: 'your-user-id', merchant: 'Example merchant',
645
+ amount: 100, currency: 'USD',
646
+ onApprovalUrl: deliverPrivatelyToCardholder,
647
+ });
648
+ const preparation = await checkout.prepare({
649
+ psp: 'braintree',
650
+ environment: 'production', // Square, Braintree and Worldpay: production or sandbox
651
+ });
652
+ // The cardholder has consented and unlocked the same approval document.
653
+ // No processor request or payment has started.
654
+ await page.getByRole('button', { name: 'Pay', exact: true }).click();
655
+ ```
656
+
657
+ `prepare()` is available on both Playwright and raw CDP controllers. It requires `amount` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The approval page on the cardholder's device must stay open. Its selected card, merchant origin, declared merchant, amount, currency, processor and environment bind one fresh request. The amount's authority is `agent`; a card token does not enforce the merchant's eventual charge amount.
658
+
659
+ | Processor | `environment` | Fresh native request |
660
+ | --- | --- | --- |
661
+ | Square | `production` or `sandbox` | Matching Square `/v2/card-nonce` host |
662
+ | Braintree | `production` or `sandbox` | Matching Braintree GraphQL host and guest `TokenizeCreditCard` mutation |
663
+ | Worldpay | `production` or `sandbox` | Matching Access Worldpay host and `/sessions/card` |
664
+ | Checkout.com | `production` or `sandbox` | Fresh guest-card JSON POST to `/tokens` on the matching API or card-acquisition-gateway host |
665
+ | Bambora | `shared` | `/scripts/tokenization/tokens` on `api.bam.shift4api.net` or `api.na.bambora.com` |
666
+ | Mercado Pago | `shared` | `api.mercadopago.com/v1/card_tokens` with a fresh card body |
667
+ | Recurly | `shared` | Form-encoded POST to `/js/v1/token` on `api.recurly.com` or `api.eu.recurly.com` |
668
+ | Spreedly | `shared` | Native iframe JSON POST to `/v1/payment_methods/restricted.json` on `core.spreedly.com` |
669
+ | Adyen | `production` or `sandbox` | The Sessions `/checkoutshopper/v1/sessions/{id}/payments` POST on the matching Adyen host family (`sandbox` is `checkoutshopper-test.adyen.com` with a `test_` client key; `production` is the live hosts with a `live_` key), carrying a fresh card's four encrypted fields |
670
+ | Fiserv | `production` or `sandbox`, with `merchantProfile` | The card frame's `/ch/payments-vas/v1/card-capture` POST on `connect.fiservapis.com` (`production`) or `connect-cert.fiservapis.com` (`sandbox`), carrying a new card's envelope under the key `merchantProfile` names |
671
+
672
+ Use `environment: 'shared'` for Bambora, Mercado Pago, Recurly and Spreedly because the same endpoint serves test and live requests. Agentcard cannot establish the processor's test mode from that URL or a credential prefix. Configure test mode through the merchant's processor account when testing. Agentcard's own `sandbox` flag remains separate. Prepared Worldpay, Bambora, Mercado Pago and Recurly requests reject saved-card and recurring request bodies; a refused request retires the local preparation. Reconcile any existing merchant attempt before creating a new attachment.
673
+
674
+ Prepare a Checkout.com checkout before the agent clicks Pay:
675
+
676
+ ```ts
677
+ await controller.prepare({ psp: 'checkout_com', environment: 'production' });
678
+ ```
679
+
680
+ Keep the phone approval page open, then click Pay once after readiness. Checkout.com Flow 1.228.0 gives its card iframe 12 seconds to return a token; approval before Pay leaves that interval for request binding and tokenization. Prepared requests must use an exact `/tokens` URL on `api.checkout.com` or `card-acquisition-gateway.checkout.com`, or the corresponding `.sandbox.checkout.com` host for `sandbox`. The request must describe a fresh card with a CVV and public-key authentication. Wallet, saved-card, reusable-token and recurring variants are refused. A native API fallback after the first CAG request cannot reuse approval. Match the actual merchant charge to the approved amount separately; a token does not enforce that amount.
681
+
682
+ Prepare an Adyen Sessions checkout before the agent clicks Pay:
683
+
684
+ ```ts
685
+ await controller.prepare({ psp: 'adyen', environment: 'production' }); // 'sandbox' for Adyen's test host
686
+ ```
687
+
688
+ After approval, click Pay once. adyen-web encrypts the agent's placeholder digits and posts its Sessions `/payments` request; the SDK pauses it and binds it to the approval, the cardholder's device (still on the approval page) encrypts the approved card under the merchant's Adyen key, and the request continues from your browser with only the four encrypted fields swapped and `brand` dropped. Adyen answers your browser, so the merchant's order state is the outcome to poll. The bound request must be a fresh `scheme` card on the approved host family with a client key of that environment; a stored-card, single-blob or store-the-card request never uses the approval. Adyen Web's own 60-second request timeout then covers only the pause, the bind and the device's encryption.
689
+
690
+ Prepare a Fiserv checkout before the agent clicks Pay:
691
+
692
+ ```ts
693
+ // The attachment names the key's merchant: attachToCdp(cdp, sessionId, { ..., merchant: 'Agentcard sandbox' })
694
+ await controller.prepare({ psp: 'fiserv', environment: 'sandbox', merchantProfile: 'agentcard_sandbox' });
695
+ ```
696
+
697
+ `merchantProfile` names the key Agentcard reviewed for the merchant, and the cardholder's approval page names that key's merchant. The checkout's `merchant` must be that merchant's name (`Agentcard sandbox` for `agentcard_sandbox`), so the approval, the preparation and the authorization name one merchant; any other name is refused with reason `merchant_profile_mismatch` before any network call. After approval, click Pay once. Fiserv's card frame encrypts the agent's placeholder and posts its card capture; the SDK pauses the capture and binds it to the approval, the cardholder's device encrypts the approved card under that key, and the capture continues from your browser with only the envelope's four members swapped. Fiserv answers your browser, so confirm the order with the merchant.
698
+
699
+ A card capture with no preparation is refused before anyone is asked: the controller reads `failed` with reason `preparation_required`, nothing reaches Fiserv, and a later `prepare()` on that attachment is refused, so attach again. Fastlane, ACH, gift and EBT captures on the same endpoint continue untouched and leave the preparation for the card capture. Every other body on that endpoint counts as a card capture, including one the SDK cannot prove card-free (not JSON, over 64 KiB, or a plaintext card): with no preparation it is refused the same way, and with one the bind refuses it as `checkout_changed`. So does a capture carrying a header that neither Fiserv's card frame nor the browser writes, such as the `traceparent` header tracing tools add: it is not the request the cardholder approved. Every Fiserv payment needs the cardholder's approval: the envelope carries no expiry, so Autopilot never pays one. Fiserv is off for every company until Agentcard turns it on for yours; until then the API serves your SDK no Fiserv recognizer, so nothing pauses on a Fiserv checkout, and `prepare()` fails with reason `processor_unavailable`. A merchant whose key Agentcard has not turned on yet fails with `unsupported_checkout`.
700
+
701
+ Prepare a Spreedly checkout before submitting the merchant's card form:
702
+
703
+ ```ts
704
+ await controller.prepare({ psp: 'spreedly', environment: 'shared' });
705
+ ```
706
+
707
+ After approval, submit the form once. The Vault replaces the card fields on your device and sends the native request through an encrypted connection to Spreedly. The merchant's environment and signed session stay unchanged. The API, Vault, relay and preparation migration must support Spreedly before you use this flow; the API refuses preparations while its relay rollout flag is disabled.
708
+
709
+ Initial coverage includes the hosted iframe's new-card request. Saved-card CVV updates, Express, wallets, bank accounts and gateway purchase APIs require separate support. A returned card token does not confirm payment; wait for the merchant's result before reporting success. Autopilot is unavailable for Spreedly.
710
+
711
+ Prepare Recurly before clicking the merchant's payment button:
712
+
713
+ ```ts
714
+ await controller.prepare({ psp: 'recurly', environment: 'shared' });
715
+ ```
716
+
717
+ After approval, submit the merchant's form once. Recurly's native request timer starts with that submission. The SDK preserves the captured US or EU endpoint and accepts a new-card form only. Legacy JSONP, saved tokens, bank accounts, alternative payments and proactive authentication requests require separate support. Prepared requests also refuse nonempty Worldpay or Cybersource risk results because those sessions can depend on the original card. A nonempty co-badged network preference is also refused until the selected card’s supported networks can be checked. A card token does not confirm a donation, subscription or purchase; confirm the merchant's result before reporting payment success.
718
+
719
+ ### Use Checkout Pro in Mexico
720
+
721
+ Attach the SDK before entering card fields on `www.mercadopago.com.mx`, then prepare the guest card checkout:
722
+
723
+ ```ts
724
+ await controller.prepare({ psp: 'mercado_pago', environment: 'shared' });
725
+ ```
726
+
727
+ The Vault checks the selected card's issuer using the merchant's current checkout configuration. Your browser receives the processor's original card-token response, and the SDK replaces the issuer in one native card association. The card number, security code and eight-digit prefix stay out of your SDK process. The encrypted relay carries the configuration lookup between the approval device and Mercado Pago.
728
+
729
+ The selected card must have the same brand and card type as the native form. Missing or ambiguous configuration, changed checkout details, and a repeated card association stop continuation. Start a fresh checkout and approval after resolving the mismatch. Final Pay and merchant confirmation still follow your existing checkout integration.
730
+
731
+ Use the matching SDK, API, Vault and relay releases together. The issuer correction covers the observed guest Checkout Pro flow in Mexico. CardForm or Bricks on merchant websites, other countries, installment changes and Autopilot need separate validation. Successful tokenization and issuer association do not establish a paid order; keep `requireMerchantResult` and a merchant receipt resolver when validating a purchase.
732
+
733
+ Braintree's native `ClientConfiguration` GraphQL query can run before, during or after preparation without using the approval. Only a single `TokenizeCreditCard` mutation can consume the prepared Braintree checkout. Prepared requests require guest card tokenization with explicit `options.validate: false`; omitted validation options, saved-card fields and `validate: true` are refused. Other GraphQL operations, batches and compound mutations are blocked. Braintree's legacy REST fallback cannot consume a prepared authorization.
734
+
735
+ Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, changes native request deadlines, or automatically retries a failed prepared checkout.
736
+
737
+ After Pay, the processor's native deadline still covers fresh authorization binding, device replay and token handoff. A disconnected or backgrounded cardholder device, or a slow transport, can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the page's own script abandons an unprepared `token` or `cse` request while the cardholder decides, the approval survives and answers the page's next request for the same purchase (see "The approval outlives the page's own request" above). If the frame that sent the request is removed, the document moves to another URL, the page closes or crashes, or a prepared request is abandoned, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so a delayed approval finishes the page's retry rather than its first request.
738
+
739
+ Lost authorization polling, local approval timeouts, or interrupted browser
740
+ handoffs produce `outcome_unknown` and block automatic retry. The thrown
741
+ `PaymentOutcomeUnknownError` carries `authorizationId` when creation was
742
+ acknowledged. `checkout.cancel()` stops the local attachment and polling; it
743
+ does not revoke a pending approval link or undo a processor payment. Cancellation
744
+ after an attempt starts is therefore unknown until reconciled. A cancelled
745
+ attachment cannot restart.
746
+
747
+ ### Adyen merchants that take the card on their own server
748
+
749
+ Some Adyen merchants have adyen-web encrypt the card into four fields and post them to their own server, which then charges the card through Adyen. No processor host is involved, so no recognizer matches that request. Agentcard reviews each such merchant as a merchant profile: its endpoint, the body rules of its card request, and the Adyen key the card must be encrypted under. The SDK arms every profile this build reviewed from the start, in `observe`, so the agent's dummy card never reaches a reviewed merchant, even before `syncRegistry()` runs or when it fails. `syncRegistry()` asks the API for each profile's status on your client (`merchant_profiles=1`), and a profile turns `enabled` only when the API serves it enabled with the same rules this SDK build reviewed and this build ships it enabled. Both adapters pause the profile's endpoint and any sibling of it (the same path under another query) from attach, and judge each request there against the profile's status at that moment.
750
+
751
+ - A request that carries no encrypted card (a basket query, a preflight) continues as before.
752
+ - A body the browser did not hand over (a blob or a file, which neither adapter can read) is not proven card-free, so any request but a `GET`, `HEAD` or `OPTIONS` that carries one is treated as a card request the profile cannot finish. Playwright reports a missing body the same way, so an empty body is treated alike in both adapters.
753
+ - A profile in `observe` never pays. Its card request is aborted before it reaches the merchant, and the adapters report `merchant_profile_observed` with the profile, the templated URL, the verdict and `keyPathSha256`, the SHA-256 of the body's sorted key paths (never a value; `null` for a body that was not read), then `unsupported_checkout`. The controller reads `unsupported` with reason `merchant_profile_observe`, and no authorization exists. Both reviewed profiles, `dunelm_was_graphql` and `cinemark_initiate`, ship in `observe`.
754
+ - A card request the profile cannot finish (storing the card, a stored card, another operation, three fields instead of four, a sibling endpoint, an unreadable body) is aborted with `unsupported_checkout` and the reason, and reads `unsupported` with reason `merchant_request_unsupported`.
755
+ - The profile's own card request, once Agentcard enables the profile for your client and this build, pays only through a preparation for that profile. Without one it is aborted before any pause, prompt or authorization: the adapters report `blocked` with `preparation_required`, the controller reads `failed` with that reason, and `prepare()` is refused on that attachment, as after any first card request. Create a new attachment and prepare before Pay.
756
+
757
+ ```ts
758
+ await controller.prepare({ psp: 'adyen', environment: 'production', merchantProfile: 'dunelm_was_graphql' });
759
+ ```
760
+
761
+ The environment is the profile's (`production` for a live Adyen key). After approval, click Pay once. The SDK sends the API the profile id with the paused request (`merchant_hosted: { profile }`, `content-type` as the only header, never Autopilot), and the cardholder's device encrypts the approved card under the key Agentcard reviewed for that merchant. The paused request's URL and body reach the API with that create, as every processor's do: the API checks the body against the profile and the amount, then keeps only its SHA-256 and the templated URL, so anything else in the body (a gift card number, a session token, an idempotency key) passes through Agentcard once and is not stored. The approval comes back as a `cse` replay with `kind: 'merchant_hosted'`, and the request continues from your browser with the four fields swapped and adyen-web's `brand` dropped, but only if the live body still hashes to the body the API checked at create (`bodySha256`). A continuation the SDK refuses sends nothing, and the SDK retires the approval (`merchant_never_retried`) so the API stops serving its ciphertext. The merchant's server charges the card and answers its own page; confirm the order with the merchant.
762
+
763
+ After hand-off, a `5xx` final answer (after any redirect), a network failure, or the page closing before an answer arrived reports `payment_outcome_unknown` and holds the checkout (`outcome_unknown`). So does the page sending the card request again before an answer arrived. Every later card request to the same profile is aborted, since it carries the agent's dummy card and a prepared approval is single use. Events name a merchant profile's endpoint by its templated URL (origin, path template and the profile's own query), so a user id, cart id or email in the merchant's URL never reaches telemetry.
764
+
765
+ #### Testing against your own Adyen TEST account
766
+
767
+ A test-mode client can run the same checkout against its own server and its own Adyen TEST account, before any reviewed profile is enabled. Declare your endpoint on the client, before any attach, as taking the card request of a profile this build reviewed:
768
+
769
+ ```ts
770
+ const vault = new VaultClient({ clientId, clientSecret, sandboxMerchants: [
771
+ { profile: 'dunelm_was_graphql', endpoint: 'https://pay.your-test-shop.example/graphql', clientKey: 'test_...' },
772
+ ] });
773
+ await controller.prepare({ psp: 'adyen', environment: 'sandbox', merchantProfile: 'dunelm_was_graphql' });
774
+ ```
775
+
776
+ The adapters then pause that exact endpoint (and its siblings) and judge its requests with the profile's body rules, whatever the profile's status. The preparation and the create carry `sandbox_declaration: { endpoint, client_key, environment: 'test' }`, and both name your endpoint's host as the merchant. The API accepts a declaration only from a test-mode client of an organization Agentcard has turned declarations on for. Your endpoint must be HTTPS on a public domain name, outside every payment processor's host, Adyen's and Agentcard's domains, and the domain of every merchant Agentcard reviewed (so `www.dunelm.com` is refused along with `was.dunelm.com`); the client checks the same rule when it is constructed. The API reads the key's public key from Adyen's TEST platform itself. The Vault page names your host, holds the bound request's key to the one the preparation read, and encrypts only Adyen's documented test cards under a `test_` key: a real card is refused on the device. A live client, or an organization without declarations turned on, gets `sandbox_declaration_refused`, and a declaration the API cannot pay gets `sandbox_declaration_invalid`, both as `CheckoutPreparationError` reasons at `prepare()`.
777
+
778
+ On a client that declares a profile, that profile id names the declaration: `prepare({ merchantProfile })` with that id pays your endpoint, never the reviewed merchant. A test-mode client can never pay a reviewed merchant on Adyen's live platform anyway; use a separate live client for that.
779
+
780
+ A raw CDP runtime arms `vault.merchantProfileUrlPatterns()` after `syncRegistry()`, beside `vault.cardUrlPatterns()`, reads `vault.merchantProfileOf(url)` and `classifyMerchantRequest(profile, url, method, body)` for each paused request, and writes a merchant-hosted approval with `substituteMerchantHostedBody(body, replay)`. Never continue a card body, or a body you could not read, to a profile's endpoint that `authorize()` did not pay.
781
+
782
+ ### Unsupported endpoints
783
+
784
+ `paymentEndpoints` is an explicit list supplied by the integrator after observing
785
+ the site's payment requests. Each guard uses a canonical origin, exact path and
786
+ mutation methods; it never examines or logs card bodies. If a guarded endpoint
787
+ is not recognized, the SDK aborts it and reports `unsupported_checkout` /
788
+ `unsupported` without creating an approval. Preflights and ordinary page traffic
789
+ continue. There is no wildcard or intercept-all fallback, and no automatic
790
+ conversion to an issued card. Unknown endpoints absent from these guards remain
791
+ untouched; the SDK cannot identify every payment request from its URL.
792
+
793
+ ### Runnable integration and local verification
794
+
795
+ `examples/existing-browser.mjs` runs against an existing provider session, using
796
+ an application-owned driver module for the agent's actions, user communication
797
+ and merchant-result resolver. Set `CHECKOUT_DRIVER` to that module's absolute
798
+ path and `CHECKOUT_CDP_URL` to the provider connection URL; optionally select the
799
+ existing tab with `CHECKOUT_PAGE_INDEX`. The module must export
800
+ `prepareCheckout(page)`, `submitCheckout(page)`, `resolveMerchantResult({page,
801
+ state})`, `onUserAction(action, {page})`, and `finishAfterPayment({page, orderId})`.
802
+ `prepareCheckout` returns the checkout options above. `submitCheckout` dispatches
803
+ the existing agent's approved purchase and returns without waiting for approval.
804
+ Install `playwright-core` in the example's host project. The SDK itself keeps no
805
+ runtime dependencies. The example is integration scaffolding, not a universal
806
+ merchant driver and not evidence of a live provider checkout.
807
+
808
+ ```sh
809
+ pnpm build
810
+ pnpm test
811
+ # Uses installed playwright-core, falling back to the monorepo backend dependency.
812
+ # Set CHECKOUT_CHROME_PATH if Chromium is not installed in Playwright's cache.
813
+ pnpm test:browser
814
+ ```
815
+
816
+ The browser fixtures never contact a payment service. The general suite uses
817
+ `psp.invalid`; the Stripe continuation suite forces `api.stripe.com` through an
818
+ allowlisted loopback proxy and a temporary self-signed TLS stub (requires the
819
+ `openssl` CLI). All other proxy destinations are rejected. These suites use an
820
+ in-process Agentcard API fixture and loopback merchant pages. The preparation
821
+ fixture denies all external traffic and waits beyond each modeled request deadline before any card request: eleven seconds for Square, sixty-one seconds for Braintree, and six and a half seconds for Worldpay, Bambora and Mercado Pago. Each fixture then checks one fresh request with an unchanged abort timer. The five-second timer matches the inspected Worldpay and Bambora source; the Mercado Pago timer is a test boundary, not a measured native deadline. The fixture exercises SDK ordering with native-shaped request bodies and synthetic responses, not processor acceptance. It proves nested-frame pause/resume, agent control during approval,
822
+ post-payment tasks in the same page, decline/expiry/cancel, unknown-outcome retry
823
+ blocking, explicit unsupported endpoint behavior, and blocking an immediate real-browser
824
+ Stripe token-to-intent fetch chain, including unrelated first intents, changed
825
+ amounts/currencies and delayed CDP acknowledgement. It does not test card
826
+ cryptography, real bank authorization or a cloud-provider deployment.
827
+
828
+ Provider/API references checked for this integration:
829
+ [Playwright CDP](https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp),
830
+ [Playwright routing limitations](https://playwright.dev/docs/api/class-page#page-route),
831
+ [Browserbase Playwright quickstart](https://docs.browserbase.com/welcome/quickstarts/playwright),
832
+ [Kernel native Agentcard integration](https://www.kernel.sh/docs/integrations/payments/agentcard).
833
+
834
+
835
+ The authenticated `GET /v2/checkout/coverage` endpoint describes direct-SDK
836
+ processor modes, limitations and verification levels. Use
837
+ `POST /v2/checkout/coverage/assess` with up to 1,000 uniquely identified cases:
838
+ `{ cases: [{ id, request_url, method: "POST", scenario: "one_time", weight: 1,
839
+ requires_3ds: false }] }`. Scenarios also include `save_card`,
840
+ `subscription_initial` and `subscription_renewal`. The endpoint assesses request
841
+ recognition, not purchases: `recognized`, `unsupported` and `unverified` are
842
+ coverage classifications, `recognized_traffic_share` is traffic-weighted, and
843
+ `purchase_success_rate` stays null without observed merchant outcomes. Do not
844
+ substitute the assessor for a browser/merchant validation run or use native
845
+ Kernel adapter coverage as evidence for this SDK's coverage.
846
+ ### Autopilot execution metadata
847
+
848
+ When the cardholder has enabled an eligible spending rule in their vault, the same authorization can run through autopilot. The SDK keeps polling the existing authorization and returns optional `executionMode: 'autopilot' | 'user_approval'` and `grantId` metadata. Older API responses remain supported.
849
+
850
+ `authorize()` accepts optional `executionMode` and `grantId` routing hints, sent as `execution_mode` and `grant_id`. These never establish permission to spend; the protected payment service checks the cardholder's signed rule. Autopilot suppresses `onApprovalUrl` while it is executing. A definite fallback to user approval delivers the existing authorization's URL once. A lost outcome remains `PaymentOutcomeUnknownError`; it does not create or submit a second payment.
851
+
852
+ Use `executionMode: 'user_approval'` to require the existing confirmation flow. Without a selected card or grant, the backend can use exactly one eligible rule; ambiguous card selection keeps confirmation. A `grantId` restricts selection to that rule. The initial executor supports only the configured controlled Stripe test flow, and production remains disabled.
853
+
854
+ For controlled hosted Stripe Checkout, both attachment functions accept `stripeCheckout: { sessionId, publishableKey, environment? }`. The default is TEST, requiring `cs_test_` and `pk_test_`. LIVE requires explicit `environment: 'production'`, a `cs_live_` Session and its `pk_live_` key. Both modes require `executionMode: 'autopilot'`, an explicit `grantId`, and a positive numeric USD `amount` in cents. The top-level document must be that exact Session on `https://checkout.stripe.com`, at `/c/pay/{sessionId}`, `/pay/{sessionId}`, `/g/pay/{sessionId}`, or `/f/pay/{sessionId}`. The SDK validates the existing URL without rewriting or navigating it. Direct `authorize()` calls use `stripeCheckoutEnvironment: 'production'` for the same explicit LIVE opt-in; the attachment functions pass it automatically.
855
+
856
+ The SDK answers dummy-card tokenization locally, then sends the browser's final native confirmation through the shared core and enclave. The local response is preparation only: it creates no authorization and makes no processor request. The final result includes `checkoutSessionId` only after the selected grant, restricted terminal response, and captured amount have been checked. Continue to confirm the merchant order through the checkout controller.
857
+
858
+ When Agentcard reports `autopilot_status: action_required` for the same authorization and grant, the SDK stops waiting and calls `onUserAction` with `reason: 'other'` and the authorization ID. The checkout controller records `requires_user_action` and holds further card requests. A direct `Agentcard.authorize()` call rejects with `PaymentOutcomeUnknownError` and reason `autopilot_action_required`. The existing authorization and reservation remain held; the SDK does not cancel or create another payment, return a processor payload, or infer a 3DS challenge. Your application's `resolveMerchantResult` can report the authoritative outcome of this same payment through `reconcile()`. Challenge execution and automatic continuation are not provided by this status report.
859
+
860
+ The shared core carries a validated billing email from that tokenization request into the final confirmation before authorization. The email stays bound to the same Session and local PaymentMethod reference. Missing email stays missing; duplicate or conflicting values are refused. The SDK does not fill an email from the Agentcard account or invent one for the merchant.
861
+
862
+ This integration requires matching backend and measured executor releases. TEST and LIVE have separate admission controls. LIVE also requires a newly authorized production grant on the selected real card, a fixed merchant account/profile with exactly one complete fixed-price USD line item and an independently reviewed operation qualification reference; the SDK option supplies none of these. LIVE deployment remains unqualified and disabled until that evidence exists. Exact observed totals do not establish atomic amount enforcement by Stripe. Subscriptions, saved-card flows and authentication continuations remain excluded. An unavailable preparation or uncertain result is declined or held for reconciliation; it cannot switch to a competing human-approval payment.
863
+
864
+ The adapters also send the observed top-level HTTPS `merchantOrigin`, such as `https://shop.example`, without a path, query or trailing slash. Direct `authorize()` callers can supply that origin explicitly. It is a routing hint; the protected adapter independently verifies the processor account, payee and amount. If a browser cannot provide its top-level URL, an explicit `merchantOrigin` option can supply the hint; otherwise the regular approval path remains available.