> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentvault.io/llms.txt
> Use this file to discover all available pages before exploring further.

# SDK API reference

> Every export of @agentvault/playwright: the catalog, signup and registry helpers, and the proxied browser object model.

Look up any call in `@agentvault/playwright`, and see where it behaves
differently from Playwright.

## Overview

The SDK has two halves:

* **HTTP helpers** that need no browser: the token catalog, signup and login
  writes, and the recipe registry.
* **A proxied browser** (`chromium.launch()`) shaped like Playwright's, where
  every call runs in the Agent Vault proxy and token fills are resolved there.

Every failure from the proxy or the API is a `SelfAgentPayError`. Branch on
its `code`; see [Error codes](/sdk/error-codes). The exceptions throw native
JavaScript errors:

* Saving a screenshot to a local file with `screenshot({ path })` can throw a
  native `TypeError` or a filesystem error, listed under `screenshot(options?)`
  below.
* `recipeSubmission()` rejects with the native `fetch` error on a network
  failure or an abort.
* `recipesForUrl()` and `submitRecipe()` reject with the native abort error when
  your `signal` aborts while the body is being read.
* `listTokens()`, `tokensForUrl()`, `recipesForUrl()` and `buyableMerchants()`
  throw a native `SyntaxError` on a successful response that is not valid JSON.
  A body cut off mid-read, or for the two registry helpers JSON of the wrong
  shape, can throw a native `TypeError`.

## Exports

| Export | What it is |
| - | - |
| `chromium` | Playwright-shaped entry point. `chromium.launch()` opens a proxied browser |
| `listTokens(opts?)` | Fetches the mock-token catalog. No browser needed |
| `tokensForUrl(url, opts?)` | The catalog narrowed to one site |
| `defaultIdentity(catalog)` | The identity to fill when none was named |
| `createLogin(opts)` | Files a signup request. Returns a request, not a credential |
| `getSignupRequest(opts)` | Reads one signup request |
| `awaitSignupApproval(opts)` | Polls until the owner decides, then returns the login |
| `setLoginUsername(opts)` | Records the handle on a login opened with only an email |
| `rotateLoginPassword(opts)` | Mints a replacement password for an agent-created login |
| `recipesForUrl(url, opts?)` | The registry recipes that apply to a page |
| `buyableMerchants(opts?)` | Merchants the registry can already buy from, best status first |
| `submitRecipe(recipe, opts?)` | Contributes a recipe |
| `recipeSubmission(id, opts?)` | Reads a submission's verdict |
| `fingerprintUrl(url)` | Strips an absolute URL to what a recipe lookup needs. Returns an input that does not parse unchanged |
| `requestTimeoutMs(method, params?)` | The deadline the SDK applies to one call |
| `sdkCapabilities` | Feature markers for this build (`fillTimeout`, `locatorCounts`) |
| `SelfAgentPayError` | The error class |
| `SdkBrowser`, `SdkBrowserContext`, `SdkPage`, `SdkLocator`, `SdkFrameLocator`, `SdkFrameElement` | The proxied browser object model |
| `TokenCatalog`, `TokenCatalogIdentity`, `TokenCatalogAddress`, `TokenCatalogCard`, `TokenCatalogLogin`, `TokenCatalogRelatedLogin`, `TokensForUrl`, `ListTokensOptions` | Catalog types |
| `ChallengeDetection`, `ChallengeType`, `SilentBlockSignature`, `HandoffOutcome` | Challenge types |
| `HandoffRecord`, `ProxyHandoff` | What `takeHandoffs()` and `recordingHandoffs()` report |
| `OwnerWaitReport`, `OwnerWaitReason`, `OwnerWaitObserver` | What `observingOwnerWaits()` reports. See [Owner waits as they happen](#owner-waits-as-they-happen) |
| `SessionOutcome`, `PageObservation`, `PageHintResult`, `PageHintKindInfo`, `NavigationResult`, `NetworkRequest`, `Screenshot`, `ActionResult`, `CardNumberInForm`, `FillReport` | Result types |
| `SdkLaunchOptions`, `FillOptions`, `SelectOptions`, `SelectOptionChoice`, `ClickOptions`, `ScreenshotOptions` | Option types |
| `NavigationFailure`, `TargetLoss`, `DropReason` | Types of the detail fields on `SelfAgentPayError` |
| `PasswordRules`, `GeneratedPasswordPolicy`, `CreatedLogin`, `SignupRequestHandle`, `SignupRequestState` | Signup and login types |
| `CheckoutRecipe`, `CheckoutRecipeSubmission`, `RegistryIndexEntry`, `RecipeSubmission` | Registry types |
| `CheckoutPrecondition`, `CheckoutRouteStep`, `CheckoutSignIn`, `SignInFieldKind`, `AccountRequiredPreconditions`, `CheckoutKindSubmission`, `CheckoutSignInSubmission`, `SignupPrecondition`, `SignupOwnerStep`, `SignupFieldKind`, `SignupPlatformId`, `SignupSubmission`, `CustomSignupSubmission`, `SignupPlatformSubmission`, `SignupKindSubmission`, `DetectSubmission`, `DetectRouteSubmission` | The parts a recipe and a submission are built from |

The module also exports `actionTimeoutFor`, `currentOwnerWaitObserver`,
`podResumeUrl`, `unreachable`, `signupPollIntervalMs`, `signupPollSleepMs` and
`signupPollTimeoutMs`. They are internal helpers, not part of the documented
API, and can change without notice.

The default export carries `chromium`, `listTokens`, `tokensForUrl`, the signup
and login helpers (`createLogin`, `getSignupRequest`, `awaitSignupApproval`,
`setLoginUsername`, `rotateLoginPassword`), the registry helpers
(`recipesForUrl`, `submitRecipe`, `recipeSubmission`) and `sdkCapabilities`.
Everything else, including `observingOwnerWaits`, `defaultIdentity`,
`buyableMerchants`, `fingerprintUrl`, `requestTimeoutMs` and
`SelfAgentPayError`, is a named export only.

The checkout playbooks are a separate entry point:
`@agentvault/playwright/playbooks`.

## Catalog helpers

### `listTokens(options?)`

Fetch the mock tokens this key may use. Needs no browser.

<ParamField path="url" type="string">
  Narrows cards and logins to those registered for this page. Cards marked
  `anySite: true` are always kept, after the ones registered for the page. A URL
  that does not parse matches no site: site-scoped cards and every login drop
  out, and the `anySite` cards remain. Identity and addresses always come back
  in full.
</ParamField>

<ParamField path="apiKey" type="string">
  Overrides `SELF_AGENT_PAY_API_KEY`.
</ParamField>

<ParamField path="baseUrl" type="string">
  Overrides the API base. Defaults to `SELF_AGENT_PAY_API_URL`. If that is
  unset, the SDK falls back to the backend URL variables that an Agent Vault
  deployment's own build sets, so code running inside a deployment needs no
  value.
</ParamField>

**Returns** a `TokenCatalog`: `userId`, `identity`, `addresses`, `cards`,
`logins`, and `relatedLogins` on a URL-narrowed catalog. Never a real card
number or CVV. Full shape: [Token catalog](/reference/token-catalog).

**Errors:** coded from the HTTP status. A 401 is `invalid_api_key`. A 429 is
`rate_limited`, with `retryAfterSeconds` when the response's `Retry-After` was
usable. Any other failing status, an unreachable API and a missing API key throw
a `SelfAgentPayError` with no `code`.

```ts theme={null}
const catalog = await listTokens();
const forThisSite = await listTokens({ url: "https://shop.example.com/checkout" });
```

### `tokensForUrl(url, options?)`

The same filter as `listTokens({ url })`, in a flatter shape. Accepts the same
options, plus `catalog` to reuse one you already fetched. Pass only a full
catalog from `listTokens()` called without `url`. The filter runs locally, and a
catalog narrowed to one site has already lost every other site's cards and
logins, so reusing it for a different host returns too little. For a new host,
pass a full catalog or leave `catalog` out to fetch again.

**Returns** `userId`, `identity`, `addresses`, `cards`, `logins` and
`relatedLogins`. `relatedLogins` names logins registered on a sibling host of
the same site, hosts only, with no tokens. They will not fill here: navigate to
that host and ask again.

```ts theme={null}
const { cards, logins, relatedLogins } = await tokensForUrl("https://www.example.com/checkout");

// One full catalog, reused for several sites without another round trip.
const full = await listTokens();
const siteA = await tokensForUrl("https://shop-a.example.com/checkout", { catalog: full });
const siteB = await tokensForUrl("https://shop-b.example.com/checkout", { catalog: full });
```

### `defaultIdentity(catalog)`

**Returns** the identity labelled `default` (the account profile), or the first
entry if there is no such label. `undefined` when the catalog has no identity.

## Launching

### `chromium.launch(options?)`

Open a proxied browser. This starts one proxy session; see
[Sessions and timeouts](/sdk/sessions-and-timeouts).

<ParamField path="apiKey" type="string">
  The API key this session authenticates with. Overrides
  `SELF_AGENT_PAY_API_KEY` for this launch only, so one process can open
  sessions for several keys. When omitted, the environment variable is used. An
  empty string does not fall back to it: with no key from either place,
  `launch()` throws "chromium.launch: no API key." before connecting. A resumed
  session reconnects with the same key.
</ParamField>

<ParamField path="proxyUrl" type="string">
  The proxy's WebSocket URL. Overrides `SELF_AGENT_PAY_PROXY_URL` for this
  launch only. When omitted or blank, the environment variable is used, then
  `ws://localhost:4000/v1`, a local development proxy. For the hosted service,
  set `wss://proxy.agentvault.io/v1`. A resumed session reconnects to the same URL.
</ParamField>

<ParamField path="tunnel" type="boolean">
  How the browser's traffic leaves. Omitted (the default): prefer a tunnel
  through your machine and fall back, with a warning, to the deployment's own
  egress: the proxy's address, or its residential egress where one is configured.
  `true`: require the tunnel and refuse to launch without one. `false`: never
  tunnel. Overrides `SELF_AGENT_PAY_TUNNEL`. See [Egress and tunnelling](/sdk/tunnel).
</ParamField>

<ParamField path="residentialEgress" type="boolean" default="false">
  Require residential egress without a tunnel through your machine, for a hosted
  agent that has no machine of its own. The proxy lets the session in only once
  its residential provider answers for the session's country. Otherwise
  `launch()` fails with `egress_unavailable` and a `retryAfterSeconds`. It never
  falls back to the proxy's own address. The country comes from your key's
  cards, then your saved addresses. When neither agrees on one country, the
  deployment's fallback country is used instead, which may not be yours. See
  [Residential egress country](/sdk/tunnel#residential-egress-country). If that egress
  stops working mid-session, every later call except `outcome()` and the
  `close()` calls fails with `egress_lost`: close and launch again. Defaults to
  `SELF_AGENT_PAY_RESIDENTIAL_EGRESS=1` when set. A working tunnel through your
  machine also satisfies it.

  Without this option, a session with no tunnel still uses the deployment's
  residential egress when it has one; the option only makes it a requirement.
  Either way the session is HTTPS only, and `goto()` throws `insecure_site` for
  a site that will not load over HTTPS. See
  [Residential egress](/sdk/tunnel#residential-egress).
</ParamField>

<ParamField path="actionTimeoutMs" type="number">
  A hard bound, in milliseconds, on each page call. A call that runs past it
  rejects with `action_timeout` and the session **stays open**, unlike a
  `request_timeout`. The call may still be running in the proxy, so treat an
  action as possibly landed. A per-call `timeout` does not raise it. Never
  applied to fills, typing, `selectOption`, `attestedCheckout`,
  `requestChallengeHandoff` or `close()`, which can wait on the owner. Defaults
  to `SELF_AGENT_PAY_ACTION_TIMEOUT_MS`. Unset means no bound. See
  [The per-action bound](/sdk/sessions-and-timeouts#the-per-action-bound).
</ParamField>

<ParamField path="resume" type="boolean" default="true">
  After a network drop, reconnect to the same session instead of ending it. Only
  a call in flight across the drop fails, with `connection_resumed`. Set `false`
  (or `SELF_AGENT_PAY_RESUME=0`) to end the session on any drop. See
  [Resume after a dropped connection](/sdk/sessions-and-timeouts#resume-after-a-dropped-connection).
</ParamField>

<ParamField path="strictApproval" type="boolean" default="false">
  Hold for the owner's approval even when the amount you report is under the
  card's approval threshold. Defaults to `SELF_AGENT_PAY_STRICT_APPROVAL=1` when
  set. A checkout whose total cannot be read always holds when a threshold is
  set, with or without this option.
</ParamField>

<ParamField path="verifiedChallengeOutcomes" type="boolean" default="true">
  Report what a challenge handoff actually did (`solved`, `released_unsolved`)
  instead of a flat `completed`. Set `false` (or
  `SELF_AGENT_PAY_VERIFIED_OUTCOMES=0`) only if your code branches on
  `outcome !== "completed"` and cannot be updated yet.
</ParamField>

These eight are the only options `launch()` reads. The options type still accepts
Playwright's `LaunchOptions`, so existing code compiles, but none of them reach
the remote browser: `args`, `headless`, `proxy`, `timeout` and the rest are
silently ignored. The proxy launches and configures its own browser.

`launch()` is the only method `chromium` has. Its type still carries the rest
of Playwright's `BrowserType`, so `chromium.connect()` or
`chromium.launchPersistentContext()` compiles, but those members are `undefined`
at runtime.

```ts theme={null}
const browser = await chromium.launch({ tunnel: true });
```

## `SdkBrowser`

| Member | Notes |
| - | - |
| `newPage()` | Opens a page in a new context. It shares the browser's cookies and sign-in, like every context of this browser |
| `newContext(options?)` | Returns an `SdkBrowserContext`. `options` is accepted for Playwright compatibility and ignored. See below |
| `outcome()` | The session's recorded outcome. See the warning below |
| `isConnected` | `false` once the session's socket has closed. Check before reusing a browser |
| `attestedCheckout(opts)` | Runs a card checkout in the confidential-compute enclave. See below |
| `takeHandoffs()` | A `HandoffRecord` of the pauses the proxy started for the owner since the last call. Drained on read. See [Handoff records](#handoff-records) |
| `recordingHandoffs(record, run)` | Runs `run` and collects the pauses for the calls made inside it into `record`, when several calls run at once. Returns what `run` returns. See [Handoff records](#handoff-records) |
| `reportsHandoffs()` | Whether this proxy reports pauses at all. When `false`, an empty record means unknown, not unattended |
| `verificationOnly()` | `true` when the session's API key is set to [fill a test card](/dashboard/api-keys#fill-a-test-card-instead) instead of the owner's card. `false` on an ordinary key **and** on a proxy too old to say. A test run that must never type a real card should refuse to fill unless this is `true` |
| `close()` | Ends the session. 30 second deadline, and the socket closes regardless |

```ts theme={null}
const browser = await chromium.launch();
if (!browser.verificationOnly()) {
  await browser.close();
  throw new Error("Refusing to run: this key would fill a real card.");
}
```

### `newContext(options?)`

The options type is Playwright's `BrowserContextOptions`, so existing code
compiles, but nothing in it reaches the proxy. The SDK sends only the browser's
id. `storageState`, `locale`, `timezoneId`, `userAgent`, `viewport`,
`extraHTTPHeaders` and every other context option are silently ignored. The
proxy configures the browser itself.

A context is not an isolated profile either. Every context opened from one
browser shares that browser's single profile, so cookies, storage and sign-in
are shared between them. For a clean profile, launch a new browser.

### Handoff records

`takeHandoffs()` returns, and `recordingHandoffs()` fills, a `HandoffRecord`:

```ts theme={null}
interface HandoffRecord {
  handoffs: {
    kind: "cvv_entry" | "purchase_approval" | "destination_approval";
    ms: number;
    host?: string;
  }[];
  unanswered: number;
}
```

<ResponseField name="handoffs" type="ProxyHandoff[]">
  One entry per pause the proxy reported. `cvv_entry`: the owner typed the
  card's security code while a fill waited. `purchase_approval`: the owner was
  asked to approve the purchase before the card number went in.
  `destination_approval`: the owner was asked to approve a new site for the
  account's saved details; `host` names the site. `ms` is how long the call
  waited for the person. A call that failed after its pause still reports it.
</ResponseField>

<ResponseField name="unanswered" type="number">
  How many calls ended **without a reply** from the proxy: a deadline that
  fired, a socket that closed, or a send that failed. Such a call may have
  paused for the owner and never said so.
</ResponseField>

Read the record this way:

| Record | Meaning |
| - | - |
| `handoffs` not empty | A person took part. The wait was the owner, not a slow site |
| `handoffs` empty, `unanswered` is 0, and `reportsHandoffs()` is `true` | Nobody was asked |
| `handoffs` empty and `unanswered` above 0 | **Unknown.** Do not report the run as unattended |
| `reportsHandoffs()` is `false` | **Unknown**, whatever the record says. This proxy does not report pauses |

For `recordingHandoffs()`, pass a record you created, initialized to zero.
The record is yours, so it keeps what was collected even when `run` throws.
Calls made inside `run` do not reach `takeHandoffs()`.

```ts theme={null}
import type { HandoffRecord } from "@agentvault/playwright";

const record: HandoffRecord = { handoffs: [], unanswered: 0 };
try {
  await browser.recordingHandoffs(record, () =>
    page.fill("#card-number", card.number, { amount: 42, currency: "USD" })
  );
} finally {
  if (record.handoffs.length > 0) {
    console.log("The owner took part in this fill.");
  } else if (record.unanswered > 0 || !browser.reportsHandoffs()) {
    console.log("Unknown whether the owner was involved.");
  } else {
    console.log("Nobody was asked.");
  }
}
```

Handoffs you start yourself with `requestChallengeHandoff()` are not listed.

### Owner waits as they happen

A handoff record says who took part once a call has finished. To know what a call
is waiting on **while** it waits, run it inside `observingOwnerWaits(observer, run)`.
The observer is told each wait as it starts, and `null` when it ends:

```ts theme={null}
import { observingOwnerWaits } from "@agentvault/playwright";

await observingOwnerWaits(
  (wait) => {
    if (wait?.reason === "new_destination") {
      console.log(`Approve ${wait.site} in the email titled 'Authorize new site: ${wait.site}'.`);
    }
  },
  () => page.fill("#email", identity.email)
);
```

| Field | Meaning |
| - | - |
| `reason` | `new_destination` (a site the account has not authorized for its saved details), a purchase approval's reason (`over_threshold`, `every_purchase`, `amount_mismatch`, `amount_unknown`, `amount_unconvertible`, `amount_unknown_strict`), `cvv_entry` (the owner types the security code live) or `challenge_handoff` |
| `expiresAt` | When the proxy stops waiting, in epoch milliseconds, if it said |
| `site` | The host the owner is asked about, if it said |

It is a function, not a browser method, so it can scope work before a browser
exists. The report never carries the approval link: that goes to the owner
alone. A proxy that predates the report sends nothing, so read silence as
unknown, not as "nobody was asked".
That call reports its own result.

### `outcome()`

**Returns** a `SessionOutcome`: `filledFieldKinds`, `deniedCount`,
`confirmationDetected`, `orderTotal` and `credentialWrites` (what the session
changed in the vault, as `label:change`; these outlive the session).

<Warning>
  `browser.outcome()` is **not** proof an order landed. `confirmationDetected` is
  set only by your own `recordOutcome()` call, never by the proxy observing the
  merchant, so a `false` is inconclusive. Once the socket has closed, `outcome()`
  is not available at all.
</Warning>

### `attestedCheckout(opts)`

Hand a card checkout to the attested enclave. The enclave reveals the card
number only inside itself and types it in its own browser.

<ParamField path="cardToken" type="string" required>
  A card **number** token, for example `av_card_1_number_u7x2k`. Any other token
  kind, including that card's CVV token, is refused with `token_not_found`.
</ParamField>

<ParamField path="plan" type="object" required>
  A self-contained, declarative plan. The enclave starts from a blank page, so a
  cart or sign-in built up in your own browser does not carry over.
</ParamField>

<ParamField path="amount" type="number">
  The order total, for the owner's approval threshold.
</ParamField>

<ParamField path="currency" type="string">
  The total's ISO currency code.
</ParamField>

**Returns** `{ last4, transactionId? }`.

Plan steps are `goto`, `click`, `fill`, `fillSecret`, `waitFor` and `wait`.
`screenshot` is refused for SDK callers. `fillSecret` must be followed by
**exactly one** state-changing step, a `click` on the merchant's pay button.
Fewer, and the card is typed into a form that is never submitted. More, and one
approval would authorize two submissions. Only `wait` and `waitFor` may follow
the click. Every navigation before `fillSecret` must target the same merchant.

Know these limits before you use it. Each case needs the ordinary in-proxy fill
instead:

| Limit | Why |
| - | - |
| A card with a spending cap | The page renders in the enclave, so the authoritative total is unknown, and an enforced cap refuses an unknown amount. `amount` here is only a declaration for audit |
| A hosted card field (Stripe Elements, Adyen, Braintree) | The plan has no frame-targeting step; the enclave fills the top-level page |
| A challenge after submit (3-D Secure, a one-time code, a CAPTCHA) | That page belongs to the enclave's browser, so neither your agent nor the handoff flow can reach it |
| A merchant that requires a CVV | The plan carries one secret, the card number. Live CVV entry types into your own session's page |

<Warning>
  **A dropped connection is not a failed checkout.** If the socket closes after the
  plan is submitted, this call rejects while the enclave carries on, and there is
  no way for a retry to join the run you lost. Retrying blind can pay twice. Check
  the merchant's order state or the session on the dashboard before sending the
  plan again; `outcome()` cannot help, because it runs over the socket that closed.
</Warning>

**Errors:** `invalid_plan`, and the attested codes on
[Error codes](/sdk/error-codes#attested-checkout). A verification-only key is
refused with `scope_denied`.

<Note>
  This path is off by default on most deployments. See
  [Attested detokenization](/security/attested-detokenization) for its status.
</Note>

## `SdkBrowserContext`

| Member | Notes |
| - | - |
| `newPage()` | A new tab that keeps the context's cookies and sign-in |
| `close()` | Real work, so it gets the ordinary request deadline |

## `SdkPage`

`SdkPage` has an `id` getter but no `url()` getter. Pass the URL you navigated
to when a helper needs it, for example `recipesForUrl(productUrl)`.

### Navigation and waiting

| Method | Notes |
| - | - |
| `goto(url, options?)` | Options: `waitUntil` (`load`, `domcontentloaded`, `networkidle`, `commit`), default `domcontentloaded`, and `timeout`, default 45000. Returns a `NavigationResult`, `{ url, sameDocument }`. In a [residential egress](/sdk/tunnel#residential-egress) session an `http://` address is loaded as `https://` on the same host, and the call throws `insecure_site` when that fails or the site redirects to `http://`. See below |
| `waitForSelector(selector, options?)` | Options: `state` (`attached`, `detached`, `visible`, `hidden`) and `timeout`, default 30000. Inside an iframe, use `frameLocator(frame).locator(selector).waitFor()` |
| `waitForLoadState(state?)` | Defaults to `load` |
| `waitForPopup(options?)` | Waits for a popup this page opened and returns it as an `SdkPage`. Option: `timeout`, default 10000. Throws if none opens in time, and `unknown_target` if the tab it opened has already closed again |
| `hasPendingPopup()` | Whether a tab this page opened is queued and not yet taken by `waitForPopup()`. `undefined` when the proxy is too old to answer |
| `cancelWaits()` | Ends outstanding waits on this page, which then reject with `wait_cancelled`. Returns how many it ended, or `undefined` when the proxy is too old to support it. A challenge handoff calls this |
| `close()` | Retires the tab. This is what stops an abandoned action |

#### `NavigationResult`

<ResponseField name="url" type="string">
  Usually where the navigation landed, reduced to scheme, host and path. The
  proxy strips the query, the fragment and any userinfo, and cuts back a path
  that carries a hosted checkout's session. It can differ from the URL you
  passed, because merchants redirect.

  Two cases return **the `url` you passed, unchanged**, with its query and
  fragment intact:

  * Any token has been filled in this session. From then on the proxy does not
    report the landing address at all, because a page can write its own address.
  * The proxy is older than this contract and sent no `sameDocument`.

  In both cases the value is not evidence of where the tab ended up. It need not
  be the final landing page.
</ResponseField>

<ResponseField name="sameDocument" type="boolean | undefined">
  The browser's own answer to whether the document stayed. `true` for a fragment
  jump or a History API route change, where the form and what was typed into it
  are untouched. `false` when the document was replaced. `undefined` from a proxy
  too old to say.
</ResponseField>

An `insecure_site` from `goto` never committed anything: the tab is still on the
page it was on. Do not treat it as a navigation that may have landed, and do not
retry the same address. See [`insecure_site`](/sdk/error-codes#page-and-fill).

When a `goto` times out after the page committed a new document, the error carries
`navigation: { committed: true, status? }`: the tab is on the new document, and whatever was typed into the
old one is gone. A `status` of 403 or 429 there is a block wall, not a slow
checkout.

### Filling and interacting

| Method | Notes |
| - | - |
| `fill(selector, value, options?)` | Resolves a token to its real value at fill time. One insert: raises `input`, not `keydown`, `keyup` or `change`. Returns a `FillReport` |
| `type(selector, text, options?)` | Character by character. Not atomic. Returns a `FillReport` |
| `click(selector, options?)` | Full Playwright actionability checks. Options: `timeout`, `force`. Returns an `ActionResult`. A proxy too old to honour `force` logs a warning once and clicks normally, waiting for the target |
| `press(selector, key)` | Waits only for attached and enabled. Clipboard chords refused after a fill. Returns an `ActionResult` |
| `selectOption(selector, choice, options?)` | A choice must name exactly one option: a string (value or label), `{ label }`, `{ value }` (value only) or `{ index }` (from 0). Takes the fill options. Resolves to nothing. See [Select choices](#select-choices) |
| `locator(selector)` | Returns an `SdkLocator` |
| `frameLocator(selector)` | Returns an `SdkFrameLocator`. The way to reach a cross-origin card iframe |

#### Fill options

`fill`, `type`, `pressSequentially` and `selectOption` take:

<ParamField path="amount" type="number">
  The checkout total this fill commits to. Pass it on the call that fills the
  **card number**. At or above the card's approval threshold, the proxy holds
  and asks the owner before any real value is typed. Ignored for non-card
  values.
</ParamField>

<ParamField path="currency" type="string">
  The total's ISO code, for example `GBP`. Always pass the real code for a
  non-USD checkout. Limits and approval thresholds are in US dollars, so a
  foreign total is converted at a daily reference rate, plus a 3% safety buffer
  for card network fees, and then checked normally. A currency with no rate, or
  a rate older than 48 hours, cannot be converted: the total is treated as
  unknown, and the fill is held for approval or refused as
  `checkout_total_unknown`. Omit it (or pass `USD`) for dollar checkouts.
</ParamField>

<ParamField path="timeout" type="number" default="30000">
  Bounds the browser work: waiting for the field to become actionable, and the
  action. It does not count time spent waiting on the owner (an approval or a
  live CVV entry). `0` means no timeout at all. Pass one whenever a miss is a
  possible outcome rather than a fault.
</ParamField>

#### `FillReport`

`fill`, `type` and `pressSequentially`, on pages, locators and frame elements,
resolve to a `FillReport`:

```ts theme={null}
interface FillReport {
  verification?: true;
  card_fields_verified?: true;
}
```

On a [verification-only key](/dashboard/api-keys#fill-a-test-card-instead), a
card number or CVV fill types a public test card instead of the owner's card,
and reports `{ verification: true, card_fields_verified: true }`. Nothing is
reserved, no spending limit is checked and the owner is not alerted for that
fill. Every other
fill reports `{}`. Use it to tell a probe of a checkout's card fields from a
purchase.

```ts theme={null}
const report = await page.fill("#card-number", card.number);
if (report.card_fields_verified) {
  // A test card went in. Do not submit this as a real order.
}
```

#### Select choices

A choice that names no option, or more than one, is refused with
`invalid_choice` before anything is sent. So is an empty one: `""`, or a label
that is blank once invisible characters and white space are trimmed, which would
select the placeholder option. `{ value }` and `{ index }` need a proxy that
supports them. Against an older proxy they are refused locally with
`invalid_choice`, because that proxy would select the placeholder and report
success. A plain string still works there.

#### `ActionResult`

<ResponseField name="openedPopup" type="boolean">
  The action opened a new tab. Switch with `waitForPopup()`. Absent is
  inconclusive: it means no tab arrived within the short window the proxy
  watches after the action. A tab the site opens later, for example a 3-D
  Secure or wallet window after a server round trip, is still queued for
  `waitForPopup()` with nothing reporting it. Before you treat the opener as the
  result of a submit, call `hasPendingPopup()`, and call `waitForPopup()` when
  the checkout plainly moved to another tab.
</ResponseField>

<ResponseField name="cardNumberInForm" type="boolean | 'unsettled' | undefined">
  Whether a card number this page filled is still in its form after the action.
  `false` means the action took the tab off that checkout or edited the field.
  `"unsettled"` means a navigation had not settled; the next click or press
  reports the settled state. `undefined` (the field is absent) means the proxy
  did not report it, for example an older proxy. It does not mean `false`:
  treat it as unknown, and neither refill the card nor discard the form because
  of it.
</ResponseField>

<Warning>
  Some fields ignore `fill()`. A field that filters digits on `keydown`, or enables
  its submit on `keyup`, never registers the value. Use
  `locator(sel).pressSequentially(token)` instead. For a form that enables its
  submit only on `change`, follow the fill with `locator(sel).press("Tab")`.
</Warning>

Hosted card widgets such as Stripe Elements ignore value-only fills entirely:

```ts theme={null}
const card = page.frameLocator('iframe[title="Secure payment input frame"]');
await card.locator('input[name="number"]').pressSequentially(catalog.cards[0].number, {
  amount: orderTotal,
  currency: "USD",
});
await card.locator('input[name="cvc"]').pressSequentially(catalog.cards[0].cvv);
```

### Looking at the page

| Method | Availability |
| - | - |
| `snapshot(options?)` | **Before any fill only.** Refused with `sensitive_data_blocked` after a token fill |
| `listFrames()` | **Before any fill only.** Same refusal. Returns `{ name, url }[]`, with each URL stripped of its query, fragment, userinfo and any hosted checkout session in its path |
| `screenshot(options?)` | Returns bytes, and is never refused after a fill: it is blacked out instead. `path` also saves a PNG |
| `observe()` | Always available |
| `detectChallenge()` | Always available |
| `locator(sel).count()`, `locator(sel).counts()` | Always available |
| `networkLog()` | Only where the deployment enables it, typically staging. Returns the page's last 25 document, XHR and fetch requests, oldest first, each `path` cut to 200 characters, as `{ method, host, path, status, agoMs }[]` (`path` can be `null`, `status` can be `"failed"`). Returns `undefined` elsewhere |
| `evaluate()` | Disabled for agent sessions: `evaluate_disabled` |

#### `snapshot(options?)`

The accessibility tree as YAML, reaching into iframes including cross-origin
ones.

<ParamField path="depth" type="number" default="12">
  How deep to walk. The proxy clamps it to 30.
</ParamField>

<ParamField path="cursor" type="number">
  Line offset for the next page of a truncated capture. Pass it with the depth
  the truncation note names: a cursor is an offset into a capture **at that
  depth**.
</ParamField>

**Returns** a string, capped at 24,000 characters. When the tree does not fit,
the last line names the `cursor` and `depth` for the next page.

A `[ref=eN]` handle is a selector: `aria-ref=e7` works for a count, a wait, a
click or a press. **A fill refuses a ref**, because an in-frame ref reaches into
an iframe from the top-level page and would bypass the check on that frame's
origin. Field values are never in a snapshot.

#### `screenshot(options?)`

Options: `path`, `timeout` (default 20000), `fullPage`. **Returns** a `Uint8Array` (a `Buffer`
at runtime) carrying `redaction` and `reason` as properties. `redaction` is
`none`, `fields` (substituted fields boxed out), `blackout` or `unknown`.

`unknown` means the proxy sent no redaction metadata, which an older proxy than
your SDK does. Treat it as possibly sensitive and possibly blacked out, never as
`none`. Only `none` tells you the pixels are the untouched page.

```ts theme={null}
const shot = await page.screenshot();
if (shot.redaction === "blackout") {
  // Not a dark page, not a failed render, and not proof a click did nothing.
}
if (shot.redaction === "unknown") {
  // An older proxy did not say. Handle it like a capture that may be redacted.
}
writeFileSync("/tmp/checkout.png", shot);
```

With `path`, the SDK also writes the PNG to that file. It checks the path
before the capture, and these failures are not `SelfAgentPayError`:

| Failure | What is thrown | Was a capture taken? |
| - | - | - |
| `path` does not end in `.png` | `TypeError` | No |
| `path` exists and is not a file, such as a directory | `TypeError` | No |
| The folder or file cannot be created or written | The filesystem error, for example `EACCES` | No |
| The write fails after the capture | The original filesystem error, with the captured image on its `screenshot` property | Yes |

In the last case, read `err.screenshot` for the image and its `redaction`,
`reason` and `securityCodeAuthorizationReleased`, which the proxy reports only
once.

Once any token is filled, the image is a solid black rectangle for the rest of
the session, across navigation, a new tab and `page.close()`. Only a new proxy
session clears it.

A capture between a card number and its security code leaves the pairing in
place. A capture that cannot complete fails rather than disturbing the page: the
number is still in the form and the security code is still the next fill, so do
not refill the number. If a capture ever comes back carrying
`securityCodeAuthorizationReleased`, fill the number again and then the code with
nothing in between.

<Warning>
  Once the owner has been handed the page for a challenge, stop taking screenshots,
  even if you have filled nothing. The proxy does not black those out, and the page
  can echo what the owner typed.
</Warning>

#### `observe()`

**Returns** a `PageObservation`: `passwordField`, `identifierField`,
`contactField`, `codeSent`, `error`, `rateLimited`, a value-free `reason`, and
`errorText`, the page's own validation messages (scrubbed of filled values; read
it whenever it is non-empty). The fields match the MCP
[`observe_page`](/mcp/tool-reference#observe_page) tool.

"On screen" means what the proxy can establish without running script in the
page: a laid-out box, not `display: none` or `visibility: hidden`, and
horizontally on the canvas. A control kept mounted at `opacity: 0` still reads as
present.

<Warning>
  A result carrying `inconclusive: true` did not finish looking. Every `false`
  beside it means unknown, and a `true` may be a match the scan could not place.
  A finished scan **omits** the flag, so test `!result.inconclusive`.
</Warning>

#### `detectChallenge()`

**Returns** `{ detected, type?, types, reason?, signature?, deliveredCode?, ambiguousOnly?, inconclusive? }`.
`type` and `reason` are absent when nothing was detected.

`type` is `"three_ds"`, `"otp"`, `"captcha"` or `"blocked"`. The first three
rank 3-D Secure above OTP above CAPTCHA, with one exception: a weak `otp`
carrying `ambiguousOnly` ranks **below** `captcha`. `types` lists every pattern
matched; a weak ambiguous `otp` is not probed once a stronger challenge is
established, so it can be absent.

`blocked` is a silent block: the site stopped answering without saying why. It
is reported only when a finished scan found no announced challenge, and then
`types` is `["blocked"]`. `signature` names the shape it took:

| `signature` | What the page did |
| - | - |
| `stalled_search` | A loading indicator stayed up, with nothing new on the page, well after your last submit |
| `search_bounce` | Your last click landed back on the page you came from, showing an error |
| `error_reference` | A dialog showed an error with a reference code where the results should be |

Hand a `blocked` page to the owner like a CAPTCHA, with
`requestChallengeHandoff()`. Do not retry, reload or search again to get past
it. The owner either gets through or confirms the site refused.

`deliveredCode: true` appears on an `otp` when the page says the code was sent
by SMS, email or another channel. No stored authenticator secret can produce
that code, so never fill `tokens.totp` for it: hand off instead. The flag is
absent when the page named no channel.

See [Handling challenges](/guides/challenges).

### Handing off

#### `requestChallengeHandoff()`

Ask the account owner with a dashboard banner, plus an email or alert on each
channel they turned on while their **A checkout needs me to clear a bank or
verification step** switch is on, with a link that streams this live page. Blocks until they finish, the wait times out (about 10
minutes), or the session ends.

**Returns** `{ outcome, challengeType, durationMs }`. `outcome` is `solved`,
`completed`, `released_unsolved`, `failed` or `abandoned`. Carry on after
`solved`. After `completed`, look at the page first. After `released_unsolved`,
detect again and hand off again. Stop after `failed`, `abandoned` or anything
you do not recognise.

While it is parked, every other page call is rejected with `challenge_required`,
so you cannot watch the owner type. Afterwards, take no screenshots: in a session
with no token fill they are not blacked out, and the page can echo what the owner
typed.

**Errors:** `challenge_required` when the handoff ended without the owner
finishing: the wait timed out, a newer handoff superseded this one, or it ended
another way. Request a new handoff only after a timeout, never after a
supersede. Also `handoff_not_applicable` (a detection carrying
`ambiguousOnly`; the page stays live and yours), `challenge_scan_incomplete`
(the proxy's own re-check could not finish; detect again and ask again), and
`unknown_target`, which has two variants:

| Message starts with | Meaning | What to do |
| - | - | - |
| `Unknown pageId` | The page was gone before the handoff started, such as a popup that closed itself | Nobody was notified. Switch to the page that opened it, or another live page, and detect again there |
| `No challenge is present on this page` | The proxy re-detected on the live page and found no challenge | The page is fine and still yours. Carry on |

<Warning>
  If the challenge ran in a popup, observe the page you came **from**. A popup that
  dismissed itself once the owner finished is a retired handle, and observing it
  answers `unknown_target` instead of telling you where the checkout got to.
</Warning>

### Hints

#### `hint(kind, target, context?)`

Tell the vault where something is on this page. `target` takes `selector`,
`frame`, `read` and `attribute`; `context` takes `login` for
`login.totp_secret`. `selector` and `frame` are each at most 512 characters.

**Returns** `{ kind, accepted, checked }`, plus what the vault read:

| Field | Meaning |
| - | - |
| `accepted` | `true`: the hint is recorded and will be used at fill time. `false`: it was dropped, because the page navigated while the vault read it, the element you named read nothing, or a framed hint's frame holds too much text. Declare it again on the current page |
| `checked` | `true`: the vault read the page for you, and `resolved` follows. `false`: a token was already filled in this session, so nothing about the page is reported back. **Not a rejection**: with `accepted: true` the hint is kept for the card fill |
| `resolved`, `amount`, `currency` | Present only when `checked` is `true`. `resolved: true` with an `amount` is a usable figure |
| `untrusted` | Why a figure was read and refused, for example `ambiguous_currency` |
| `stored`, `digits`, `period` | For `login.totp_secret`: the secret was stored, and the code's shape. Never the secret |

After any token fill in the session, an `order.total` hint answers `{ accepted: true, checked: false }` with no `resolved`, `amount` or `untrusted`. That holds only for selectors built from ids, classes, element names and presence-only attribute tests such as `[data-total]`. A `selector` or `frame` that compares an attribute value, such as `[data-testid="total"]`, is refused with `invalid_hint` after a fill, and one declared before the fill is skipped when the card fill reads the total.

An `order.total` hint matching more than 8 elements, or an element over 4,000
characters, adds nothing (`accepted: false`). With `frame`, the hint also reads
the frame's whole text and every other frame on the page. Over 20,000
characters in the frame, over 16,384 counting the page's own text, or more than
32 frames on the page, the hint is dropped: `accepted: false` even though the selector matched (after a token
fill, `{ accepted: true, checked: false }`, and it is dropped at the card fill).
The total then stays unknown. Point at a total outside the frame if there is
one. Otherwise a card with an approval threshold asks the account holder, and
a card with spending limits is refused with `checkout_total_unknown`. See
[Totals inside an iframe](/guides/page-hints#totals-inside-an-iframe).

```ts theme={null}
await page.hint("order.total", { selector: ".summary__grand-total" });
await page.hint("order.total", { selector: "#total", frame: "#paddle-checkout iframe" });
await page.hint("order.total", { selector: "#order-total", read: "attribute", attribute: "aria-label" });
```

**Errors:** `invalid_hint` for a malformed hint, a selector outside the
plain CSS grammar hints accept, a `selector` or `frame` over 512 characters, an attribute value comparison declared after
a token fill, or a fifth distinct hint of the same kind on one page (the limit
is 4 per kind, per page; repeating a hint does not count, and a navigation
clears the count), and `hint_unresolved` when a storing hint found
nothing usable where you pointed. Fix what they name and retry. `hint_uncertain`
means a storing hint's write may have committed and only its answer was lost.
Do **not** retry it: enrollment never overwrites a stored secret, so a retry
is refused with `hint_unresolved` and reads like a second failure. Carry on,
and tell the owner to check the login on their **Logins** page.

Full guide: [Page hints](/guides/page-hints).

#### `hintKinds()`

**Returns** the kinds this deployment accepts, as
`{ kind, purpose, when, reads, attributes, allowAfterFill }[]`. `reads` lists
the read modes (`text`, `attribute`), `attributes` the attribute names
`read: "attribute"` may name for that kind, and `allowAfterFill: false` means
the reply stops reporting what was read once a token has been filled. Kinds are
added server side, so ask rather than assume.

### Recording

#### `recordOutcome(options?)`

Record that this session reached a confirmed checkout. This is the **only** call
that sets `confirmationDetected`. Optionally pass `orderTotal`, a display string
such as `"$42.00"`. It is kept only when it is at most 24 characters and carries
a currency sign, a currency code or two decimal places. Anything else, a bare
number like `"1234"` included, is silently left out. A whole total of exactly
three or four digits is also dropped even with a marker (`"$737"`, `"USD 1234"`),
because it cannot be told apart from a security code: write it as `"$737.00"`
or `"USD 1,234"`. It checks nothing on the page, so call it only once your own
check shows the order landed.

**Returns** the updated `SessionOutcome`, the same shape as `outcome()`. A
session that filled any token is recorded with no receipt image, because a
confirmation page can echo a filled value. That is expected, not a failure.

**Errors:** `popup_pending` when a live tab this page opened is still queued and
no `waitForPopup()` has taken it, because the 3-D Secure or wallet step may be
running there.

<Warning>
  Never use `recordOutcome()` to probe a login. It sets `confirmationDetected`
  unconditionally, so a failed login would be recorded as a completed order.
</Warning>

## `SdkLocator`

| Method | Notes |
| - | - |
| `count()` | Never waits. Does not spend the card pairing |
| `counts()` | `{ count, visible }`: attached matches and how many are on screen, from one call. Never waits. `visible` is `undefined` when the proxy is too old to report it, the selector matched more than 50 elements, which it will not check one by one, or the page navigated during the check |
| `first()`, `nth(n)` | As Playwright. `nth` counts from 0 |
| `fill(value, options?)` | Token-resolving. Takes the fill options. Returns a `FillReport` |
| `type(text, options?)`, `pressSequentially(text, options?)` | Not atomic. A target that vanishes mid-run may leave a recorded transaction unreversed. Return a `FillReport` |
| `click(options?)`, `press(key)` | Return an `ActionResult`. `click` takes `timeout` and `force`; `press` takes no options |
| `selectOption(choice, options?)` | Same choices and options as `page.selectOption`, including the `invalid_choice` refusals. Resolves to nothing |
| `waitFor(options?)` | Options: `state` (`attached`, `detached`, `visible`, `hidden`) and `timeout`, default 30000 |

## `SdkFrameLocator` and `SdkFrameElement`

`page.frameLocator(selector).locator(inner)` returns an `SdkFrameElement` inside
an iframe. It has `fill`, `type`, `pressSequentially` (each returning a
`FillReport`), `click`, `press`, `count`, `counts` and `waitFor`, with the same
options as on `SdkLocator`. It has no `selectOption`, `first` or `nth`. This is
the supported way to target a hosted
payment frame, because `listFrames()` is refused once a token has been filled.

| Integration | Frame | Inputs |
| - | - | - |
| Stripe Payment Element | `iframe[title="Secure payment input frame"]` | `number`, `expiry`, `cvc` |
| Stripe legacy Card Element | `iframe[title="Secure card number input frame"]` | `cardnumber`, plus `exp-date` and `cvc` in their own frames |

Input names vary by integration, so probe with `count()` and fall back rather
than blocking for 30 seconds.

## Signup and login helpers

These call the HTTP API, not the proxy, and need a key the owner has allowed to
create logins. Without it they refuse with `signup_not_enabled`. Each takes
optional `apiKey` and `baseUrl`. The [agent signup guide](/guides/agent-signup)
walks through the flow.

### `createLogin(opts)`

<ParamField path="website" type="string" required>
  The site's registrable domain, for example `doordash.com`. Locked to that
  domain and its subdomains.
</ParamField>

<ParamField path="email" type="string" required>
  A mailbox the agent controls. The owner sees it before approving.
</ParamField>

<ParamField path="username" type="string">
  Only a handle the site has already accepted. Approval stores it.
</ParamField>

<ParamField path="minLength, maxLength, noSymbols, symbols, noRepeats" type="PasswordRules">
  The site's stated password rules. The password is 12 to 64 characters, 24 by
  default. A `maxLength` below 12 is refused with `password_too_short`. A
  `minLength` above 64, or greater than `maxLength`, is refused with
  `invalid_policy`: on a site that needs more than 64, the account holder adds
  the login. `symbols` filters the vault's safe set, `!@#$*+-_=.?`, and can
  never widen it. A `symbols` with none of those characters is refused with
  `invalid_policy`. The password always holds a lowercase letter, an uppercase
  letter and a digit, plus a symbol unless `noSymbols` is set.
</ParamField>

**Returns** `{ requestId, status, expiresAt }`. `status` is `"pending"`, or
`"approved"` when the owner has [pre-approved signups](/dashboard/logins#pre-approved-signups) for this site with this
exact email. Pre-approval completes at once for at most 2 signups per listed
site and, by default, 60 per account in a 24-hour window. Past either ceiling, or
when another approval check fails, the request comes back `"pending"` instead.
See [Limits](/reference/limits#ceilings). The 500-login ceiling is the
exception: an account that already holds 500 logins gets `login_limit` straight
away, pre-approved site or not, and no request is created.
An approved handle still carries no tokens: poll `requestId` right
away and the first answer has them. A pending result is a stop for
`createLogin`, not a retry point.

**Errors:** `signup_not_enabled`, `too_many_pending`, `login_limit` (the
account already holds 500 logins, counting ones the account holder added; no
request is created), `invalid_website`, `invalid_email`, `invalid_username`,
`invalid_policy`, `password_too_short`, `rate_limited`, `write_outcome_unknown`.

`rate_limited` means the key's shared budget of 20 credential writes per minute
is spent. `createLogin`, `setLoginUsername` and `rotateLoginPassword` all draw on
it. Wait `retryAfterSeconds`, then call again. `too_many_pending` is also a 429,
but it clears only when the owner decides a waiting request, so it carries no
`retryAfterSeconds`.

### `getSignupRequest(opts)`

Read one request by `requestId`. Optional `timeoutMs` bounds this one poll:
default 30000, at most 30000, and never under 1000.
**Returns** `{ status: "pending", expiresAt }`,
`{ status: "denied" }`, `{ status: "expired" }`, or
`{ status: "approved", login, passwordPolicy, passwordOwnerManaged? }`.
`login` is the new login's [catalog entry](/reference/token-catalog), so its
label is `login.label` and its tokens are under `login.tokens`, with
`login.hasEmail` and `login.hasUsername` saying which identifier it carries.

`passwordPolicy` describes the password the vault generated. `passwordOwnerManaged`
is `true` when the account holder replaced that password from their dashboard
before you polled: the policy then describes a password that no longer exists,
the token still fills the account holder's password, and `rotateLoginPassword`
is refused for this login for good. If the site rejects the password, tell the
account holder. Absent or `false` means the generated password is still in place.

**Errors:** `request_not_found`, `scope_denied`.

### `awaitSignupApproval(opts)`

Poll until the owner decides. Takes `requestId`, plus `timeoutMs` (default 15
minutes) and `intervalMs` (default 5000, never faster than 2000). A 429's
`Retry-After` is honoured before the next poll, within the same deadline.

**Returns** the approved `{ login, passwordPolicy, passwordOwnerManaged? }`.

A failing poll that another tick can get past (a dropped connection, a 408, 429
or 5xx) is absorbed and the wait carries on. A refusal that says the caller is
wrong (`request_not_found`, any other 4xx, a missing key) is thrown at once.

**Errors:** `signup_denied`, `signup_expired`, `approval_timeout` (the wait
ended; the request is still live), and, thrown at once, `request_not_found` and
`scope_denied` (the owner removed the approved login from this key's access).

```ts theme={null}
const { requestId } = await createLogin({ website: "shop.example.com", email: "agent-inbox@example.com" });
const { login } = await awaitSignupApproval({ requestId });
```

### `setLoginUsername(opts)`

Record a handle the site has already accepted. Takes `label` and `username`.
**Returns** `{ login }`. An exact replay of the same handle is idempotent.

Only the API key that created the login can set its username, and only within
seven days of the login being created. Seven days is the default; a deployment
can set a different window. Any other key, or a later call, gets `not_found`,
the same answer as an unknown label. An exact replay of a handle already stored
still succeeds after the seven days.

**Errors:** `username_already_set`, `not_found`, `invalid_username`,
`daily_limit`, `rate_limited` (with `retryAfterSeconds`; an exact replay of a
handle already stored is not charged), `write_outcome_unknown` (re-send the same
handle).

### `rotateLoginPassword(opts)`

Replace the generated password on a login an agent created, when the site
rejected it. Takes `label` plus the password rules, with the same bounds as
`createLogin`: a `minLength` above 64 or a `symbols` outside the safe set is
`invalid_policy`, and a `maxLength` below 12 is `password_too_short`. The mock
tokens do not change. Never use it on a form that asks for the current password.

Only the API key that created the login can rotate its password, only within
seven days of the login being created, and only while the account holder has not
replaced the password. Seven days is the default; a deployment can set a
different window. Any other key, or a later call, gets `not_rotatable`, the same
answer as an unknown label.

**Returns** `{ login, passwordPolicy, rotated: true }`: the login's catalog
entry with the same tokens, and the rules the new password was generated under.

**Errors:** `not_rotatable`, `not_found`, `invalid_policy`,
`password_too_short`, `daily_limit`, `rate_limited` (with `retryAfterSeconds`),
`write_outcome_unknown` (stop and hand it to the owner).

## Registry helpers

```ts theme={null}
const { merchant, platform, signup, signupPlatform } = await recipesForUrl(productUrl);
const candidates = await buyableMerchants();
const { id } = await submitRecipe(recipe, { note: "Reached a filled card form." });
const verdict = await recipeSubmission(id);
```

| Helper | Returns | Notes |
| - | - | - |
| `recipesForUrl(url, opts?)` | `{ merchant, platform, signup, signupPlatform }`, each a recipe or `null` | Public data, no key. Pass an absolute URL: it is stripped first with `fingerprintUrl`. Options: `baseUrl`, `signal` |
| `buyableMerchants(opts?)` | Merchant index entries `{ key, kind, platform?, status }`, `verified` first, then `partial`, then `unverified`. Dead ends excluded | Public data, no key. Options: `baseUrl`, and `platform` to filter. No `signal` |
| `submitRecipe(recipe, opts?)` | `{ id, key, status, verdict }`, with `status` always `pending` and `verdict` empty | Needs the key. Options: `note`, `verificationRequested`, `apiKey`, `baseUrl`, `signal` |
| `recipeSubmission(id, opts?)` | `{ id, key, status, verdict }`; `status` is `pending`, `approved`, `merged`, `rejected` or `needs_human` | Needs the key. Options: `apiKey`, `baseUrl`, `signal`. While `status` is `pending`, call it again with the same `id`. Do not resubmit |

`recipesForUrl` strips the URL because the lookup is unauthenticated and can
reach logs: for an absolute URL such as `https://shop.example.com/cart`, the
query, fragment and any hosted-checkout token (a Stripe `cs_...` session, a
Shopify checkout token) never leave your process. Stripping needs a URL that
parses. A relative or malformed string, such as `shop.example.com/cart`, is sent
to the registry exactly as you passed it, so never pass one that contains a
session token.

`recipesForUrl` and `buyableMerchants` set **no** `code` on failure: a non-2xx
reports the status in its message. Branch on which call threw. A malformed
successful body, or an abort mid-body, escapes as a native error, not a
`SelfAgentPayError` (see [Overview](#overview)).
`submitRecipe` and `recipeSubmission` code from the response body:
`invalid_recipe` (with `validationErrors`), `rate_limited` (the budget of 20
in any 24 hours, with no `retryAfterSeconds`), `not_found` and `unauthorized`.

<Warning>
  A `note` sent with `verificationRequested: true` is published with the recipe,
  word for word, once approved. Send what shows the purchase happened and nothing
  that identifies who made it.
</Warning>

## Utilities

### `SelfAgentPayError`

| Field | Set when |
| - | - |
| `code` | The proxy or API supplied one. Optional: configuration and tunnel failures carry none |
| `retryAfterSeconds` | The server said how long to wait. From a usable `Retry-After` on a `rate_limited` from `listTokens()` or the signup and login helpers. From the proxy's wait on a `rate_limited` when connecting or filling a token. On `egress_unavailable` when `launch()` is refused |
| `validationErrors` | `invalid_recipe` |
| `numberLanded` | `paired_cvv_refused`: `confirmed`, `unconfirmed` or `displaced` |
| `pairingHeld` | A refused security-code fill: whether the card pairing is still armed |
| `targetLoss` | A lost page: `pre_lookup` or `in_flight` |
| `cardNumberInForm` | A click or press failed and the proxy reported whether its card number is still in the form. Same values as on `ActionResult`. Absent means not reported, which is unknown, not `false` |
| `navigation` | A `goto` that timed out after committing a new document |
| `writeSettled` | `write_outcome_unknown`: whether a status line arrived |
| `dropReason` | A `connection_closed` whose close code said why: `transport_lost`, `proxy_restart`, `proxy_error`, `superseded` or `resume_refused` |

### `requestTimeoutMs(method, params?)`

**Returns** the deadline in milliseconds the SDK applies to one call. See
[Sessions and timeouts](/sdk/sessions-and-timeouts#the-deadlines).

### `sdkCapabilities`

Markers for features that live inside existing methods, so they cannot be
detected by checking for a method: `fillTimeout` (fill options carry `timeout`
to the proxy) and `locatorCounts` (locators answer `counts()`). Absent means an
older build.

## Related

<CardGroup cols={2}>
  <Card title="Sessions and timeouts" icon="clock" href="/sdk/sessions-and-timeouts">
    Deadlines, and which errors end a session.
  </Card>

  <Card title="Error codes" icon="triangle-exclamation" href="/sdk/error-codes">
    Every code, and whether retrying is safe.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.