Skip to main content
Look up any error code, see whether a retry can work, and write retry logic that never charges anyone twice.

Overview

Every failure from the proxy or the API is a SelfAgentPayError with a stable, machine-readable code. A few failures escape as native JavaScript errors instead, so catch more than SelfAgentPayError around these calls:
  • Saving a screenshot to a local file: see screenshot(options?) in the API reference.
  • recipeSubmission(): a network failure, or an abort through your signal, rejects with the native fetch error.
  • recipesForUrl() and submitRecipe(): an abort through your signal while the response body is being read rejects with the native abort error.
  • listTokens() (and tokensForUrl(), which calls it), recipesForUrl() and buyableMerchants(): a successful response whose body is not valid JSON throws a native SyntaxError. A body cut off mid-read, or for the two registry helpers JSON of the wrong shape such as null, can throw a native TypeError.
The signup and login helpers (createLogin(), getSignupRequest(), awaitSignupApproval(), setLoginUsername(), rotateLoginPassword()) always throw SelfAgentPayError. Through the MCP server, a refused call returns a tool error carrying the same message.
1

Branch on code first

Messages are prose and get reworded. No control flow should depend on their exact wording.
2

Read the message for overloaded codes

Some codes cover situations whose retry answers differ, and the message is the only signal. They are marked Overloaded below. Match loosely on a distinguishing phrase, treat “no match” as the more conservative branch, and re-check when you upgrade. Where a wrong guess could charge someone twice (card_blocked, error), check the merchant’s own order state instead.
3

Treat a missing code as an unknown outcome

code is optional. Errors raised inside the SDK around a proxy call, such as configuration and tunnel failures, and HTTP failures whose body carries no code, have none. Apply the double-charge rule.
The overloaded codes are insecure_page, approval_denied, card_blocked, login_blocked, unknown_target, malformed_response, limit_reached, challenge_required, cvv_entry_required, card_live_fill_unsupported, attestation_required, token_not_found, attested_checkout_unavailable, auth_failed, invalid_plan, rate_limited, approval_timeout and error.

The double-charge rule

A missing code, or a connection-level code, means the request got no verdict, not that it never landed. Any close rejects every in-flight call, and the connection can drop after a click or submit reached the merchant.
  • On a read, it is safe to repeat.
  • On anything that could have changed the checkout, treat it as an unknown outcome. Confirm against durable merchant state (the order or confirmation page, an account order list, a confirmation email) before replaying.
browser.outcome() is not durable merchant state: a confirmationDetected: false there is inconclusive, because only your own recordOutcome() call sets it. See Sessions and timeouts.

What a refusal costs

A refusal before anything is recorded costs the account nothing, and so does one whose reversal succeeded: the plan’s payment quota counts only fills that released a value. Where a refusal comes after the transaction was recorded, the proxy reverses it on a best-effort basis. If that reversal fails, the recorded transaction keeps counting against quota and the card’s limits. The detail for each code says when this can happen. Retrying a permanent failure wastes the run. Giving up on a temporary one abandons a checkout the account holder was about to approve. Retrying one whose reversal quietly failed can record the purchase twice.

Retry legend

Spending, limits and approval

A spending limit the account holder set on the agent card stopped the purchase: per transaction, or a daily, weekly or rolling 30-day window. The rolling windows apply on production deployments only; preview, development, test and staging deployments check only the per-transaction cap.The plan’s quotas send plan_quota_reached, or limit_reached on an older deployment (see the note below).A total that could not be established has its own code, checkout_total_unknown.This is Agent Vault’s own limit, not the account holder’s bank’s: a refusal charges and holds nothing at the bank. The account holder raises a cap in the Agent Vault dashboard.Agent Vault counts a card-number fill’s amount the moment it lands, before any order exists. A fill whose order was never placed still counts until the window rolls over, so re-filling in one session can reach a cap with nothing bought. When that is the case for a window cap, the message says so.Retry depends on the cause:
  • Caps, with auto-pause on. autoPauseOnLimit is on for every agent card created through the dashboard. Any breach of the card’s own limits, per transaction included, flips it to limit, and the next attempt is refused before the new total is even evaluated. The account holder has to resume the card. The exception is a funding card with another active agent card: a fill goes through when any active agent card permits the merchant, so a smaller cart or an expired window can succeed with nobody resuming anything.
  • Caps, with auto-pause off. A per-transaction cap is recomputed against the new total, so shrinking the cart works straight away. A window cap is recomputed from trailing history, so the same purchase succeeds later.
  • A plan quota, on an older deployment: behaves like plan_quota_reached. The agent card quota never clears on its own, so do not loop on it.
So shrink the cart by all means, but check whether the card auto-paused before assuming that is enough. Only a cart you cannot shrink below the cap needs the account holder for the cap itself.
On a deployment that predates plan_quota_reached, the plan quota cases arrive as limit_reached. Branch on plan_quota_reached where you have it and keep handling the plan case under limit_reached as the fallback. Never treat an unrecognised code as retryable.
The order total could not be established, so the purchase was never checked against the account holder’s controls. This is not a limit being exceeded. Either the page’s total could not be read, or its currency could not be converted into the denomination the caps use. It fires only on a card that has some limit configured; an uncapped card completes the same checkout.Retry: worth one immediate attempt. A total that had not finished rendering, or a refreshed exchange rate, can make the next attempt succeed. If it does not clear at once, it is not a rendering delay. The message names which of three things happened:Two things not to conclude. Raising a limit will not clear it, so do not send the account holder to the dashboard. And it is not a promise the card has room: no cap was checked, so the total may still exceed one once it can be read.If the message names a currency problem, no page hint will help. For an unsupported currency, use a checkout in a supported one. For stale rate data, retry later: the rates refresh once a day.
A quota on the account plan, not a card setting. The message names which of two causes:
  • The payment allowance is exhausted. It counts only payments that released a value; denials and reversals do not count. The window is a trailing 30 days, not a calendar month, so it clears as the oldest payment ages out. An upgrade may raise the ceiling but is not a reset: the count is kept across plan changes. Free and Plus have this cap; Enterprise has none.
  • The agent card is beyond the plan’s agent card allowance. This never clears by waiting. It needs an upgrade, or enough older cards removed. Only agent cards linked to a live funding card hold slots, and slots go to the oldest of those first, so deleting a newer one, or one with no funding card, changes nothing.
Retry: neither clears by retrying this checkout. Do not send the account holder to check their card.
Overloaded. Two unrelated waits share this code.
  • Card fill. The purchase was at or above the agent card’s confirmAbove threshold, so the fill waited for the account holder’s approval, and that wait ended with no decision. No real value was filled. Retry the fill, which asks the account holder again.
  • Signup (awaitSignupApproval). The SDK’s own wait ended; the request is untouched and still live. The message says which of two shapes:
    • The polls were answering and the request was still awaiting a decision.
    • The last poll failed (a dropped connection, a deadline, a 5xx, or a response this build could not classify), so the wait established nothing and the account holder may already have approved. The error carries the failure as cause.
    Either way, call awaitSignupApproval or getSignupRequest again with the same requestId. An approval you could not read is already a login, so the next poll finds it and listTokens() lists it. Never call createLogin again: that opens a second, unrelated request and spends another of the account’s five outstanding slots.
A poll failure that can never come good (request_not_found, or any other 4xx) does not arrive here. awaitSignupApproval throws it at once with its own code.
Overloaded. Four cases, and only the first is a refusal:The selectOption refusal exists because that path has no approval gate: a merchant-controlled dropdown listing every code from 000 to 999 could otherwise harvest a real security code.On a host change, the proxy attempts to reverse the recorded transaction, best effort. If the backend was unavailable, the transaction survives and a retry records a second one. If the error returns, or the spend looks higher than expected, reconcile in the dashboard rather than retrying.
The total on the page has risen above the figure the account holder’s controls saw when the card number went in (or, on a saved card, when the security code did). On a card with a spending limit, a code filled on its own gets limit_reached instead. Usually a shipping or tax recompute on the last step, an upsell a click accepted, or a changed quantity or plan.The action that would submit is refused, and so is every other click, key press, typed value and dropdown choice on that checkout while the total stays above the authorized figure.Retry: not by re-filling the card. The number is already in the merchant’s form, and a second fill types a second number and records a second charge for one purchase. If the checkout brings the total back down by itself, the next action goes through. If not, stop and tell the account holder the new total so they can decide.On a saved-card checkout, where the security code was the only real value filled, filling that code again re-runs the check against the total the page shows now.

Card and merchant refusals

The account holder used the account-wide stop (Pause all agents) on the dashboard. While it is on, nothing resolves for any agent on the account: card numbers, security codes, login passwords, authenticator codes, identity fields and every saved address. It is not a verdict about the token you used. Compare card_paused, which is one funding card’s agent cards being paused.Retry: no, and not with any other token either: a different card, login or address fails the same way. Stop the run and tell the account holder their stop is on; only they can lift it. Your session stays open and the key is not revoked, so once they resume, retry in the same session.
The site is not an authorized destination for this account. This is account level and applies to every secret type (card, security code, identity field and address), so it is not a verdict about the token you used. Compare merchant_blocked, which is one agent card’s own allow or block list.Retry: not until the account holder authorizes the site, and not with a different token either. Before this error, the first fill on a new site opens a new-site approval request and waits up to 15 minutes while the account holder approves or denies it from the email or the dashboard banner. Tell the account holder as soon as that wait starts, so they know to approve it.When the fill is refused, the error’s decision field and its message say what came of the request:Once the site is authorized, retry in the same session: the account’s authorized sites are read fresh on the next fill. See New-site approvals and destinations.
None of this funding card’s agent cards is active, and at least one is paused. Not necessarily all of them: the code is card_paused whenever any of its agent cards is paused, even when the others sit at limit.Retry: not while it is paused. Once the account holder resumes it, retry in the same session: the proxy reloads the card’s controls on the next fill, so no new key or session is needed.Ask the account holder to check all of this card’s agent cards on the dashboard rather than resuming the first paused one. With a mix of paused and limit cards, resuming an arbitrary one can leave the one that permits this merchant still at limit. The error does not say which card that is.
The site is not permitted by the agent card’s merchant rules: either an allow list that does not name it, or a block list that does. The code is the same for both.Retry: not with this card on this site while the rules stay as they are. The checkout itself may still be possible:
  • The merchant question is decided from active agent cards only. A card whose rules do permit this site is ignored while it is paused or at limit, and resuming it can make the same card work immediately. Check that first.
  • If listTokens() offers another in-scope funding card, its rules may permit this merchant.
  • Failing that, a different permitted site may still work.
  • If the account holder wants this site allowed, tell them the host and let them decide how. Adding it to an allow list does nothing while it is on an active block list, and removing it from a block list does nothing while an allow list omits it.
Overloaded. Two cases, neither an enclave failure (those have their own attested_fill_* codes):
  • An ambiguous card number matching several differently configured cards. Resolution is deterministic, so the same token collides on every attempt. Use a different card, or have the account holder remove the collision or narrow the key’s scope.
  • A binding mismatch on the live vault fill: the vault token’s owner does not match the card’s account, or the revealed last four does not match the card’s. Nothing was typed, and the proxy attempts to reverse the transaction, best effort. If the backend was also unavailable, the transaction survives: if the run looks like it spent quota, check the recorded transactions on Activity and the agent card’s spend. Retrying is futile until the account holder or operator corrects the card.
Retry: never blindly.
Overloaded. Three cases. The code does not reveal which:
  • The login is URL locked and this page is not its registered site.
  • The login has no website set at all.
  • The login resolved for the right iframe, but that frame navigated to another origin before the value was typed.
The card number was already typed into the merchant’s form. Only the security code was withheld. Whether the merchant completed the charge anyway cannot be established from here.Two things can cause it.
  • The checkout could no longer be proven to be the authorized one: the total became unreadable, changed, or changed currency, or the page moved to another site or loaded a new document.
  • The pairing between the number fill and the code fill was lost: a navigation, a step that replaced the document, or another fill in between. The code then needed a fresh approval, and that approval was declined, ran out, or could not be settled.
The message says which, and what became of the number’s transaction. A recorded one is not reversed. The code also covers a number fill that recorded nothing, so do not assume a charge the message does not name.numberLanded on the error says which variant you have:Retry: never the whole card fill into a field that holds a number: it types a second number and records a second charge. After a declined approval, abandon and tell the account holder. If you abandon for any reason, tell the account holder a card number was entered at this merchant. Never report it as “no card data was entered”.
Overloaded. Either this deployment has not enabled live card fills, or it has and the vault answered the reveal with a non-2xx status (the message carries the status). One cause carries no status: a successful response whose value is empty or masked, meaning the vault cannot reveal this card or the token is not a card. The proxy fails closed rather than type a mask.The status cases are raised after the transaction was recorded, and the reversal is best effort. If the backend was also unavailable, the transaction survives and a retry records a second one. If the dashboard shows more spend than the run accounts for, reconcile instead of retrying.
Overloaded. Four cases, and read the message rather than assuming it is one of these:On plaintext, anyone on the network path between the proxy and the merchant could read a card number, password or authenticator code. Plaintext is never relayed through your machine; see Egress and tunnelling.A loopback http:// page (localhost, a *.localhost name, 127.0.0.0/8 or ::1) is not refused, so a local demo works. On a deployment that uses a static upstream proxy, there is no loopback exception: those pages are refused too. See URL locking.Two exceptions to “nothing was substituted”, on type, pressSequentially and selectOption:
  • If the field is replaced while the value is going in, the proxy retries on the replacement. A plaintext form there is refused, but some characters may already be on the page. The field cannot be read back, so do not re-send blind once the form is fixed. Recovery depends on the value:
    • Card: clear the field, then fill the number again through the ordinary fill. This records a fresh charge. Or hand the checkout to the account holder.
    • Password, identity or address: nothing was charged. Clear the field with fill(selector, "") and re-send the same token.
    • Select: selecting again replaces the previous choice.
    • Authenticator code: the page may already have consumed it. Re-read the step before sending another.
  • A tokenized selectOption can have its control replaced mid-call. By then the choice has reached the page, so it counts as a fill in the session’s outcome whatever the refusal says. The matching Activity entry is best effort.
A card’s reservation is released on a best-effort basis, never promised. A hold may still stand, so filling the card again can consume its limit twice. Reconcile as for card_resolution_uncertain rather than assuming the refused attempt cost nothing.On a security-code fill, read pairingHeld on the error. true means the card number’s one-shot pairing is still armed, so the retried code is the next fill to send. false means the refusal came after the pairing was spent, and the code will ask the account holder again.

Security code (CVV)

Overloaded. The security code is never stored, so its token cannot resolve to a stored value. Where live security code entry is enabled, the fill pauses for the account holder, and this code covers five endings. The message says which. The pause lasts about 3 minutes, or less while the proxy is shutting down, and the account holder’s link stops accepting a code at the same moment the pause ends.Restarting in a new session fills the card number again. If the number was already filled in the failed session, that fill is still recorded: it counts toward the card’s spend limits and the account’s payment quota, and it is not reversed. The new fill records a second spend attempt for the same purchase, and it can hit a spending cap or plan_quota_reached. Check Activity or reconcile before you restart.On a deployment without live security code entry, the message says “Live CVV entry is not yet available”. Do not retry: every attempt fails the same way. Stop and tell the account holder that this card cannot be used on this deployment for a checkout that asks for the security code. See Live CVV entry.

Challenges and handoff

Overloaded. Three cases:
  • The page is showing a 3-D Secure, one-time-code or CAPTCHA step. The account holder has to act first; see Handling challenges.
  • A handoff is in progress and this action was suspended. Actions refused during a takeover are meant to be retried once the account holder is done.
  • A requestChallengeHandoff() timed out or was superseded.
A superseded handoff means a newer one for the same key is already live, quite possibly the one the account holder is completing now. Re-requesting supersedes that one, so wait for the active handoff instead. Only a timed-out handoff should be restarted.
handoff_not_applicable: the only thing detected was a field that might be a code input. It was flagged because of fills you made during this sign-in, not because a challenge is showing, so there is nothing for the account holder to act on. Do not discard or recreate the tab. Fill tokens.totp if this is the authenticator step of a sign-in you just performed; otherwise report the step as unresolved.challenge_scan_incomplete: a frame detached during the scan, or a probe ran past its budget. Nobody was notified. If detection keeps returning inconclusive, the page is not settled: give it a moment before deciding anything.

Authentication and scope

listTokens() returns only the cards and logins this key already allows. A token that went stale after the account holder narrowed the key is answered by a fresh one, and another allowed card or login may suit the same checkout. So refresh the catalog first.Only when nothing returned fits do you need the account holder. Once they widen the scope, it takes effect on the very next attempt, with no new key or session.
Overloaded.
  • A token of the wrong kind. The attested checkout accepts only a card number token (for example av_card_1_number_u7x2k) and refuses anything else, including that card’s own CVV token, before any lookup. This is an agent bug: re-reading the catalog returns the same token. Pass a card number token.
  • A stale token. The account holder deleted or replaced the resource, or removed the field it stood for (a login’s email, username or authenticator secret). Re-read the catalog once: it is rebuilt from the current cards and logins, so you get a fresh, valid token and the checkout continues.
  • An unvaulted card. The catalog lists every in-scope card regardless of vault status, so a refresh returns the same token. The account holder has to re-add the card on the dashboard.
  • An empty identity field. The account holder has not filled in that field (a last name, a phone) of the identity you used; the message says so, and the identity’s missing array lists the field. Identity tokens never change, so do not compare tokens here: ask the account holder to fill it in on their profile, or on that labelled identity, then refresh and retry once the field is no longer in missing.
For the other cases, refresh once. If the token comes back identical, stop.
unauthorized reaches you in two places:
  • On the socket: the first frame was not an auth message, or the key was empty. Every call in flight when the proxy closes with 4001 also gets this code.
  • From the recipe helpers: a missing or malformed Authorization: Bearer header. recipeSubmission() hits this when no key is configured, because it still sends an empty bearer. Set SELF_AGENT_PAY_API_KEY or pass apiKey.
listTokens() maps every 401 to invalid_api_key, so you never see unauthorized there.auth_failed wraps every failure of the backend auth call, including a network error, a non-2xx response or a malformed reply, not only a rejected key. A transient backend blip looks identical, so one retry with backoff is worth it.

Page and fill

A session on the deployment’s residential egress is HTTPS only. An http:// address you pass to goto() is loaded as https:// on the same host and path, and on the same port when the address names one. When that HTTPS attempt fails (no TLS on the host, a certificate the browser will not accept, no answer), or when the site’s server redirects to an http:// address, the navigation stops with this code. It never falls back to http. The message says which case it was:
  • “This site doesn’t support HTTPS, so Agent Vault won’t load it (insecure_site).”
  • “This site redirected to a plain http:// address, so Agent Vault didn’t follow it (insecure_site).”
Both end “Nothing was sent over plain http.” Nothing committed: the tab is still on the page it was on, and anything typed there is still there. Retrying the same address fails the same way every time, in this session or a new one.Only goto() returns this code. When a click() or press() starts a navigation that is stopped the same way, it gets the browser’s own navigation error, such as net::ERR_SSL_PROTOCOL_ERROR, and the tab stays on its page. A navigation the page starts by itself is stopped silently.A name that does not resolve, a slow host that runs past your timeout, and a failure of the residential provider itself are not this code: they keep their ordinary error, and a provider that keeps failing ends the session with egress_lost. An insecure_site does not mark the session’s outcome as an error on its own, so a run that goes on to check out elsewhere still finishes normally.
A modifier chord (Control+c, Control+v, Control+x and their Meta and ControlOrMeta spellings) was pressed after a real value was substituted into the page. A chord could move a filled card number or password somewhere the agent controls, so it is refused once the session is tainted. An untainted session is unaffected.The refusal covers the whole class and fails closed on modifier names it does not recognise, so probing spellings will not find a way through. Plain keys (Tab, Enter, arrows, characters) still work. To clear an input, click it and type over it rather than reaching for Control+a.
A token fill is verified after it is applied. The field did not keep what was written: the page emptied it, or a maxlength or input mask cut it short. The value still reached the page, so it counts as a token use, and any transaction and card-fill alert stand. The site’s own submit will read something else.A control that moves the value elsewhere (a one-time-code widget spreading a code across boxes, a tag input lifting it into a hidden field) is not a rejection. A tokenized selectOption is verified the same way: the page’s own handlers put a different option back.Either way, check the merchant’s own state before repeating anything that could charge.
The choice carried none of label, value and index, or its label or value was empty. Left to the browser, an empty choice matches a placeholder option and reports success with the dropdown unchanged. Nothing was selected.The SDK raises the same code before sending for an object with none or more than one of those keys, and for { index } or { value } on a proxy that does not support them, rather than risk selecting the wrong option.
Overloaded. Several causes, and two want opposite responses:On the character-by-character paths (page.type, locator type, pressSequentially), the target can vanish after the transaction was recorded, and it is not reversed even though no character landed. Retrying records a second transaction. If the field disappeared mid-run, reconcile rather than retry blind.For a lost page the error carries targetLoss:
The proxy stopped waiting for this one call: a click, press, count or other page call that Playwright never answered inside the proxy’s deadline (120 seconds, or the call’s own timeout plus 30 seconds, capped at an hour). Unlike request_timeout, the transport is still up and the browser, pages and cookies are intact. The call was abandoned, not cancelled, so an action may still land later. The message says which kind of call it was.
  • A read, wait or count: retry once.
  • An action: close that page first (page.close() stops the action; if the close itself comes back unanswered, call it again). Then read the merchant’s order state, and only if no order is there continue in a new tab of the same context.
A stalled screenshot counts as an action. A stalled card fill may already have recorded its transaction: check the dashboard before filling again. Full procedure: Recover from unanswered.

Backend and protocol

The instance you reached is draining and refused to start a session. It arrives on the auth frame, before any browser, tunnel or transaction exists, and the socket then closes with 1013 (“Try Again Later”). Nothing was allocated or spent. A fresh connection lands on an instance that can serve it. Treat a repeat as a deployment rolling out and back off a little.
Overloaded.
  • The shape case is always before anything is recorded, so a retry cannot charge twice. Whether it helps depends on the cause the message names: transient corruption clears, a version mismatch or a field the backend never sent fails every time. Retry once; if the message is unchanged, it is a deployment problem for the account holder.
  • The wrong-card case on the attested path is a critical anomaly: a payment may already have been attempted with another card. Reconcile rather than retry.
A shape failure after the transaction was recorded arrives as card_resolution_uncertain instead.
The call that records the transaction timed out with its outcome unknown, or returned a response that could not be read after the record was written. The transaction may exist with no id to reverse it. No card value was entered: the reveal happens after this call. Raised on both the attested path and the ordinary fill.Retry: no. Reconcile first. Retrying either records the purchase twice or consumes a cap for money never spent. There is no safe variant of this code.
Overloaded. The catch-all for any failure the proxy could not classify under a more specific code. Usually a Playwright failure, but it also covers backend trouble (a failed fetch, a non-2xx response, unparseable JSON, a misconfigured deployment) during token resolution or approval, before any value reaches the page.Verify before retrying. “Backend failure” is not by itself a licence to retry:
  • A genuinely pre-execution failure never touched the merchant. A network blip clears with backoff; a misconfiguration will not.
  • A live vault fetch that threw (DNS failure, connection reset, its own timeout) escapes as error after the transaction was recorded. The reversal is best effort. Before repeating a checkout that failed while the card was being revealed, check the recorded transactions on Activity and the agent card’s spend.
  • On the attested path, an enclave service failure (a 5xx, a mid-response reset, a timeout, a malformed success) arrives as error after the enclave may already have typed the card. The proxy keeps the transaction and marks the outcome unknown. Treat it as unverified.
  • A failure while waiting for a selector is safe to repeat. A click or submit that timed out may already have reached the merchant: check the merchant’s order state first.
browser.outcome() helps only if it returns confirmationDetected: true, and that only repeats your own earlier recordOutcome() call. This is the one code where the message is all you have.

Attested checkout

These codes come from browser.attestedCheckout(). The same table, with the feature’s status, is on Attested detokenization.
Overloaded. Seven causes share this code:Nothing was spent in any case, so nothing needs reconciling. Tell the account holder which configuration cause the message names.
  • attested_fill_failed: no card value was entered, no budget was consumed, no counters moved. The proxy uses this code only once it has confirmed the reversal landed. Also covers the run exceeding its overall time budget. Retry is safe.
  • attested_fill_unreversed: the same failure with that promise withdrawn, usually a backend outage. The recorded transaction counts against the card’s limits and the account’s quota until someone reconciles it. Also raised when the enclave filled a card whose last4 is not the authorized one and the reversal failed, which is a critical anomaly: another card may have been charged. Surface it to the account holder.
  • attested_fill_incomplete: the card may already have reached the page and the order may exist. The proxy attempts to notify the account holder, but that is best effort and can fail silently, so tell the account holder yourself. Only the merchant’s own order state can settle it. A blind retry is how you get two orders.
Overloaded. Refused before any transaction was recorded.
  • A malformed plan. Retrying the same plan fails the same way, so fix it. One of:
    • A screenshot step (refused for SDK callers).
    • A step type other than goto, click, fill, fillSecret, waitFor or wait.
    • A step missing a required field: click, waitFor and fillSecret need a non-empty selector; fill needs selector and a string value; goto needs a non-empty url; wait needs a finite ms.
    • A destination that fails the host check.
    • A plan that never fills the secret.
    • A plan where fillSecret is not followed by exactly one click and then only wait or waitFor steps.
  • A lost tunnel. A session that required tunnel egress lost its tunnel while the account holder was deciding the approval (“its tunnel is no longer available”). The plan is fine. Relaunch with chromium.launch({ tunnel: true }) and run the same plan.

Page hints

hint_unresolved is deliberately ambiguous: “matched nothing” and “matched, but not a usable secret” are one outcome with no counts attached, because a caller that could tell them apart could read a filled value back a bit at a time. Retrying the same hint changes nothing. Fixable causes: reveal the “can’t scan the QR code?” text option, or re-check the login label and the site.For login.totp_secret it also covers refusals nothing can clear: a second login registered for this site, an otpauth:// link naming an account that is not the login’s stored username, or a login stored with no username. The account holder then enrols two-factor from the dashboard.hint_uncertain: the secret may already be stored. Enrolment never overwrites an existing secret, so a retry would be refused with hint_unresolved and look like a second failure. Do not retry. Ask the account holder to check the login and finish two-factor setup there if the secret is missing. See Page hints and TOTP.

Catalog and registry calls

These codes come from the HTTP response rather than the proxy.
Overloaded. Four different sources share this code; tell them apart by which call threw, never by a missing retryAfterSeconds.
  • The proxy (chromium.launch() or a fill): too many new sessions, or too many token resolutions, for this key or its account in a fixed one-minute window. Nothing was opened, filled or charged. retryAfterSeconds is set: wait that long. A refused fill leaves the session open, so retry it on the same page. See Limits.
  • The catalog (listTokens()): a per-key and per-account, fixed one-minute window. The response carries Retry-After in whole seconds, surfaced as retryAfterSeconds. Wait that long. The window is fixed, so retrying early is refused again but does not push it out. A Retry-After stripped or unparseable on the way leaves retryAfterSeconds undefined too; do not read that as the recipe budget.
  • Recipe submissions (submitRecipe()): a rolling 24-hour budget with no Retry-After. Each submission counts for 24 hours after it was made, so a place frees when the oldest one in the last 24 hours turns 24 hours old. Stop submitting until then rather than polling.
  • Credential writes (createLogin, setLoginUsername, rotateLoginPassword): 20 writes per key in a fixed one-minute window, shared by all three. It carries Retry-After, surfaced as retryAfterSeconds; retry after it. Compare daily_limit, which clears only 24 hours after the first change counted in its window.
recipesForUrl() and buyableMerchants() set no code at all on failure: a non-2xx reports the status in its message. Branch on which call threw.

Signup and login writes

These come from createLogin, getSignupRequest, awaitSignupApproval, setLoginUsername and rotateLoginPassword (MCP: create_login, get_signup_request, wait_for_signup_approval, set_login_username, rotate_login_password). See Agent signup.
too_many_pending is per account, not per key, because it protects the account holder’s attention. It has deliberately no Retry-After, which would disclose when the oldest request was filed. It clears as requests are decided, expire or are cleared by the account holder. Do not poll tightly.login_limit is a standing ceiling, not a rate. It is checked when createLogin opens the request and again at approval, because the count can move during the pending window. At the first check, createLogin fails with login_limit (HTTP 403) and no request is created, even for a pre-approved site. A request that passed the first check can still stall as pending, with the reason shown to the account holder.daily_limit reaches the agent directly from setLoginUsername and rotateLoginPassword. The same check runs when the account holder approves a signup request; the change counts against the budget only then. A request that fails it stays pending. The agent never sees daily_limit for it, so a request stuck at pending longer than expected may be waiting on this budget.The budget is 25 changes by default, in a 24-hour window that starts at the first counted change and resets 24 hours after it. It does not reset at midnight. A refused change is not counted and does not move the window.
setLoginUsername and rotateLoginPassword change only a login that the calling API key created through a signup request. Another key on the same account is refused, even one with the signup permission. Both also stop working seven days after the login was created, which is when the signup was approved. Seven days is the default; a deployment can set a different window.The two calls refuse with different codes. setLoginUsername answers not_found (HTTP 404) and rotateLoginPassword answers not_rotatable (HTTP 409). Each gives that one answer for every reason, so the code does not tell you which rule failed or whether the label exists. One exception: the creating key re-sending the exact handle a login already carries still succeeds after the seven days, because it writes nothing.
One code for five misses: an unknown id, a malformed id, an id that belongs to a different kind of record, a request that belongs to another key, or (rarely) a since-deleted login behind an approved request. They are collapsed so a key cannot use the poll to probe other keys’ requests.awaitSignupApproval does not absorb this one: it stops the wait immediately with this code rather than polling a request that does not exist.
username_already_set: setLoginUsername sets a username, it does not rename one. The existing handle may come from an earlier call, a username passed to createLogin, or the account holder. Re-sending the handle the login already carries is not this error: an exact replay is idempotent and returns success. The refusal does not say which handle is on file, so a second guess only makes the record worse.signup_denied: opening a new request for the same site costs another outstanding slot and another notification. Do it only if something about the request genuinely changed.signup_expired: distinct from approval_timeout, which means the SDK’s wait ended on a request that is still alive. A new createLogin opens a new request and notifies the account holder again, so do not loop on it.
Raised by the SDK on createLogin, setLoginUsername and rotateLoginPassword when no usable answer arrived: the connection dropped, the 30 second deadline expired, the server answered 5xx (a gateway error can land after the change committed and the account holder was notified), or a success arrived without the promised fields. Never raised by the read-only polls.error.writeSettled separates two shapes. true: a status line arrived, so the write is done or refused for good and only its description was lost. false: the request may still be in flight.What to do depends on the call:
scope_full (the key’s allowed-logins list is at its cap) and label_unavailable (the account has run out of numbered labels for a site’s name) are real failure reasons, but they are checked only at signup approval and are the account holder’s business. To the agent, the request simply stays pending.Do not write a branch expecting either. The visible symptom is a request that stays pending until it ends as signup_expired. Only the account holder can fix it, by widening the key’s scope or deleting a login that holds one of the site name’s 50 labels, then approving again. See Limits.

Session and connection

browser.outcome() cannot help here, for two reasons: it runs over the socket that just closed, and even in an open session a false is inconclusive. The check has to be the merchant’s own order state.Rebuilding browser state that changes nothing (navigations, waits, reads) is safe. “Replay the run” is not: an order-submit click that completed earlier is still completed, and repeating it because a later read timed out submits it twice.See Sessions and timeouts.

Hosted MCP server

These apply only to the hosted MCP server. How hosted sessions, handles and retries work is on On the hosted server.

Discovery responses

Your MCP client reads two discovery documents to find the Agent Vault sign-in page. Both answer GET and HEAD without sign-in. When the server holds no fresh copy and cannot get a usable one from the sign-in service (the fetch fails, the sign-in service answers with an error or something other than JSON, or the document names a different issuer), the second request answers: This is separate from token checks. A client that already holds a valid access token is not affected, and a client that follows authorization_servers from the first document reads the sign-in service’s metadata directly instead.

Sign-in responses

The hosted server checks the access token on every request, before any tool runs. When the check fails, the server answers at the HTTP level, not with a tool error. Your MCP client handles these responses itself. The agent never sees a tool result for the call. The full invalid_token challenge looks like this, with your deployment’s address in place of the placeholder:
The client needs a new access token. Retrying the tool call cannot fix this.
  1. If the client holds a refresh token, it may get a new access token without asking the user, and the call goes through.
  2. Otherwise the client marks the Agent Vault server as needing sign-in, and its tools fail until the user signs in again. Some clients open the Agent Vault sign-in page in the user’s browser by themselves. Others wait for the user to start it, the same way as the first sign-in: in Claude Code, run /mcp and choose Agent Vault; in Codex, run codex mcp login agent-vault. See Connect to the hosted server.
A full sign-in can create a new connection, which starts with nothing granted. If the next call answers access_not_chosen, choose access for the new row on Connected apps.
There is no insufficient_scope response. The server does not read the token’s scopes. What a connection may use is decided on Connected apps, and a connection without access gets the access_not_chosen tool error below.

Protocol responses

The server answers these itself, at the HTTP level. A 404 or 405 comes before the token is checked. The JSON-RPC errors come after it, so a request without a valid token gets a sign-in response first. A JSON-RPC error body looks like {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Request too large"}}. Arguments that fail a tool’s schema are not a protocol response: they come back as a tool error whose text begins MCP error -32602, and the tool does not run.

Tool errors

These errors can occur whenever a call needs a new credential: any call that starts a session, and submit_checkout_recipe or get_recipe_submission when called without a session. A call on a session that is already open uses that session’s credential and is not refused this way. Nothing ran and no session was started. The agent gets a tool error carrying the message only, not the code. Match loosely on the phrases quoted below, and treat anything else as the conservative case.

Gateway messages

The hosted server answers these itself, as tool errors. None carries a code. The first three refuse the call before any tool runs, and are not kept for retries, so sending the call again runs it.

Sessions and timeouts

Which errors end a session, and the deadlines.

Troubleshooting

Symptoms and fixes.