> ## 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.

# HTTP API

> Every Agent Vault endpoint you can call without the SDK or the MCP server, with parameters, responses and errors.

Call Agent Vault from any language for everything except driving the browser.

## Overview

| Endpoint | Purpose | Auth |
| - | - | - |
| `GET /v1/tokens` | Read the mock token catalog | API key |
| `GET /v1/recipes` | Look up checkout recipes | None |
| `POST /v1/recipes` | Contribute a recipe | API key |
| `GET /v1/recipes/submissions/{id}` | Read a submission's verdict | API key |
| `POST /v1/logins/requests` | Ask the account holder to approve a new login | API key with signup |
| `GET /v1/logins/requests/{id}` | Poll a signup request | The API key that filed the request |
| `POST /v1/logins/username` | Record the username on an agent-created login | API key with signup |
| `POST /v1/logins/password` | Replace an agent-created login's password | API key with signup |

Driving the proxied browser uses the proxy's WebSocket protocol, not this API.
The [Playwright SDK](/sdk/install) and the [MCP server](/mcp/setup) are the two
supported clients for it.

## Base URL

Your deployment's API base URL, which the SDK reads from `SELF_AGENT_PAY_API_URL`.
Copy it from the dashboard's framework picker on **Overview** or **API keys**.

```bash theme={null}
export SELF_AGENT_PAY_API_URL=https://api.agentvault.io
```

## Authentication

Send an API key from the dashboard as a bearer token:

```bash theme={null}
-H "Authorization: Bearer $SELF_AGENT_PAY_API_KEY"
```

Recipe reads need no authentication. Every other endpoint does.

| Status | `code` | Meaning |
| - | - | - |
| 401 | `unauthorized` | No `Authorization` header, or not in `Bearer` form |
| 401 | `invalid_api_key` | The key is unknown **or** revoked. The two are deliberately indistinguishable |

## Errors

Every error response is JSON with a stable `code` and a human-readable `error`:

```json theme={null}
{ "error": "Rate limit exceeded. Retry after the window resets.", "code": "rate_limited" }
```

Branch on `code`, never on `error`, whose wording can change. Every authenticated
response carries `Cache-Control: no-store`. Full list:
[Error codes](/sdk/error-codes).

***

## `GET /v1/tokens`

Returns the mock token catalog this API key may use.

| | |
| - | - |
| **Auth** | API key |
| **Rate limit** | Fixed one-minute windows, per key and per account. Per key: 60 on Free, 300 on Plus, 600 on Enterprise. Per account, across all its keys: 120 on Free, 600 on Plus, 1,200 on Enterprise |
| **Caching** | `no-store` |

### Query parameters

<ParamField query="url" type="string">
  Narrows `cards` and `logins` to those registered for this page, and adds
  `relatedLogins`. Cards marked `anySite: true` are always kept, after the ones
  registered for the page. `host` is accepted as an alias. A value that does not
  parse matches no site: every site-scoped card and every login drops out, but
  every `anySite` card is still returned. Identity and addresses are returned
  unchanged.
</ParamField>

### Request

```bash theme={null}
curl -sG -H "Authorization: Bearer $SELF_AGENT_PAY_API_KEY" \
  "$SELF_AGENT_PAY_API_URL/v1/tokens" \
  --data-urlencode "url=https://shop.example.com/checkout"
```

### Response

`200` with the catalog. Every field is described in
[Token catalog](/reference/token-catalog).

<Note>
  The URL filter is a convenience over data the key can already see. It is not a
  security boundary: the proxy's [URL locking](/security/url-locking) is.
</Note>

### Errors

| Status | `code` | Meaning |
| - | - | - |
| 401 | `unauthorized` | No bearer token |
| 401 | `invalid_api_key` | Unknown or revoked key |
| 429 | `rate_limited` | The key's or the account's per-minute budget is spent. `Retry-After` gives the wait in whole seconds, at least 1 |

Both windows are fixed, not sliding. The account budget is shared by every key on
the account, so creating more keys does not raise it. A refused request records
nothing, so retrying early is simply refused again. A successful fetch also marks the key as used, which
is how the dashboard shows that your agent has connected.

***

## `GET /v1/recipes`

Looks up the checkout recipe registry.

| | |
| - | - |
| **Auth** | None |
| **Caching** | Per-URL lookup: `no-store`. Index and full bundle: `public, max-age=300` |

### Query parameters

<ParamField query="url" type="string">
  Returns the recipes that apply to this page. Send only the scheme, host and
  path: strip the query, the fragment and any hosted-checkout token first.
</ParamField>

<ParamField query="full" type="string">
  `1` returns every recipe body. Ignored when `url` is set.
</ParamField>

With neither parameter, returns the index.

### Request

<CodeGroup>
  ```bash Per URL theme={null}
  curl -sG "$SELF_AGENT_PAY_API_URL/v1/recipes" \
    --data-urlencode "url=https://shop.example.com/product/thing"
  ```

  ```bash Index theme={null}
  curl -s "$SELF_AGENT_PAY_API_URL/v1/recipes"
  ```

  ```bash Full bundle theme={null}
  curl -s "$SELF_AGENT_PAY_API_URL/v1/recipes?full=1"
  ```
</CodeGroup>

### Response

A per-URL lookup returns four slots, each a recipe or `null`:

```json theme={null}
{
  "merchant": { "id": "shop.example.com", "kind": "merchant", "platform": "woocommerce", "status": "partial" },
  "platform": { "id": "woocommerce", "kind": "platform", "status": "verified" },
  "signup": null,
  "signupPlatform": null
}
```

The index and the full bundle return `{ "recipes": [...] }`. Each entry has these
fields:

| Field | Always present | Meaning |
| - | - | - |
| `key` | Yes | The recipe id. Signup and signup platform recipes are prefixed `signup:`, for example `signup:auth0` |
| `kind` | Yes | `merchant`, `platform`, `signup` or `signup-platform` |
| `platform` | No | The platform recipe a `merchant` or `signup` recipe names, or `custom`. Omitted on `platform` and `signup-platform` entries |
| `status` | Yes | `verified`, `partial`, `unverified` or `dead-end` |
| `registryVersion` | Yes | The registry release the recipe was loaded from, or `submission` for a contributed recipe |

With `full=1` each entry also carries the recipe body under `recipe`.

```json theme={null}
{
  "recipes": [
    { "key": "shop.example.com", "kind": "merchant", "platform": "woocommerce", "status": "partial", "registryVersion": "submission" },
    { "key": "woocommerce", "kind": "platform", "status": "verified", "registryVersion": "0.1.0" }
  ]
}
```

<Warning>
  A per-URL lookup is unauthenticated, and its URL can reach logs before the
  `no-store` header applies. Never send a raw hosted-checkout URL: a Stripe `cs_...`
  path or a Shopify `/checkouts/...` path carries a session token. The SDK's
  `recipesForUrl()` strips it for you.
</Warning>

### Errors

None specific to this endpoint. A URL that does not parse returns four `null`
slots.

***

## `POST /v1/recipes`

Contributes a recipe. Screening runs after the request returns.

| | |
| - | - |
| **Auth** | API key |
| **Rate limit** | 20 submissions per account in any rolling 24 hours, by default |
| **Body limit** | 64 KB |

### Body

The recipe JSON on its own, or wrapped:

<ParamField body="recipe" type="object" required>
  The recipe. See [Recipe format](/registry/recipe-format).
</ParamField>

<ParamField body="note" type="string">
  Free text for the maintainer, up to 2,000 characters. For a verification
  request, the out-of-band evidence. **Published word for word** as the recipe's
  `evidence` if the claim is approved.
</ParamField>

<ParamField body="verificationRequested" type="boolean">
  Says a real purchase completed (or, for a signup recipe, an account was
  created). Requires a non-empty `note`. The recipe goes live at the submitted
  status and the claim waits for a maintainer, who alone can set `verified`.
</ParamField>

`$`-prefixed field names, such as an editor-only `$schema` pointer, are removed
on parse.

### Request

```bash theme={null}
curl -X POST "$SELF_AGENT_PAY_API_URL/v1/recipes" \
  -H "Authorization: Bearer $SELF_AGENT_PAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d @recipe.json
```

### Response

```json theme={null}
{ "id": "SUBMISSION_ID", "status": "pending" }
```

Status `202`. Read the outcome from `GET /v1/recipes/submissions/{id}`.

### Errors

| Status | `code` | Meaning |
| - | - | - |
| 400 | `invalid_recipe` | The body is not JSON, or the recipe failed validation. Validation problems are listed in `errors` |
| 401 | `unauthorized`, `invalid_api_key` | See [Authentication](#authentication) |
| 413 | `invalid_recipe` | The body is over 64 KB. It is counted as it streams and cancelled at the cap |
| 429 | `rate_limited` | The account's submission budget is spent. **No `Retry-After`**, because the window is a rolling 24 hours: a place frees 24 hours after each counted submission |

***

## `GET /v1/recipes/submissions/{id}`

Returns the verdict for a submission made by this account.

| | |
| - | - |
| **Auth** | API key |

### Path parameters

<ParamField path="id" type="string" required>
  The `id` returned by `POST /v1/recipes`.
</ParamField>

### Request

```bash theme={null}
curl -s "$SELF_AGENT_PAY_API_URL/v1/recipes/submissions/$SUBMISSION_ID" \
  -H "Authorization: Bearer $SELF_AGENT_PAY_API_KEY"
```

### Response

```json theme={null}
{
  "id": "SUBMISSION_ID",
  "key": "shop.example.com",
  "status": "approved",
  "verdict": []
}
```

`status` is `pending`, `approved`, `merged`, `rejected` or `needs_human`.
`verdict` lists the screening findings in fixed text, and is empty while
pending. See [Submission outcomes](/registry/contribute#submission-outcomes).

While `status` is `pending`, poll again. Do not resubmit.

### Errors

| Status | `code` | Meaning |
| - | - | - |
| 401 | `unauthorized`, `invalid_api_key` | See [Authentication](#authentication) |
| 404 | `not_found` | No submission with this id for this account |

***

## Signup and login endpoints

These four endpoints let an agent open and maintain site logins for the account
holder. The three `POST` endpoints need an API key the owner has granted the
**signup** permission, which is off by default. See
[Agent signup](/guides/agent-signup).

Shared by all four:

| | |
| - | - |
| **Auth** | API key |

The three `POST` endpoints (`/v1/logins/requests`, `/v1/logins/username` and
`/v1/logins/password`) also share these rules:

| | |
| - | - |
| **Permission** | The key's signup permission |
| **Body limit** | 4 KB of JSON |
| **Rate limits** | 20 writes per key per minute |

The account's budget of agent credential changes, 25 by default in a 24-hour
window, is not shared the same way. The window starts at the first counted
change and resets 24 hours after it, not at midnight. `POST /v1/logins/username` and `POST /v1/logins/password`
charge it when they write, and answer `daily_limit` when it is spent.
`POST /v1/logins/requests` does not charge it when it files an ordinary
`pending` request. The account holder's approval charges it later, and if the
budget is spent at that moment, the request stays `pending`: your poll never
shows `daily_limit`. A pre-approved signup that completes at once does not
charge it either. It draws on the pre-approved allowance instead, described
under `POST /v1/logins/requests` below.

`GET /v1/logins/requests/{id}` only reads. It takes no body and is not counted
against the per-minute limit or the 24-hour budget. It answers only the key that
filed the request.

Password rule fields, accepted by the request and password endpoints. Every field
is optional. A value of the wrong type is ignored, so the vault's stronger
defaults apply. The generated password is 12 to 64 characters, 24 by default,
and always holds a lowercase letter, an uppercase letter and a digit, plus a
symbol unless `noSymbols` is set.

<ParamField body="minLength" type="number">
  Shortest password the site accepts. A whole number, at most 64. A higher value
  is refused with `invalid_policy`, because the vault never generates a password
  longer than 64. Such a login has to be added by the account holder.
</ParamField>

<ParamField body="maxLength" type="number">
  Longest password the site accepts. A whole number. Below 12 is refused with
  `password_too_short`.
</ParamField>

<ParamField body="noSymbols" type="boolean">
  The site rejects punctuation. Set it only from a stated rule.
</ParamField>

<ParamField body="symbols" type="string">
  The exact punctuation the site accepts. It can only narrow the vault's safe
  set, `!@#$*+-_=.?`. A value with none of those characters is refused with
  `invalid_policy`.
</ParamField>

<ParamField body="noRepeats" type="boolean">
  The site rejects three identical characters in a row.
</ParamField>

### `POST /v1/logins/requests`

Files a signup request. Usually the account holder has to approve it, and
nothing is created until they do. If they have put this site and this exact
email on their [**Pre-approved signups**](/dashboard/logins#pre-approved-signups) list, the login is created right away
instead.

<ParamField body="website" type="string" required>
  The site's domain, for example `shop.example.com`.
</ParamField>

<ParamField body="email" type="string" required>
  The mailbox to sign up with.
</ParamField>

<ParamField body="username" type="string">
  A handle, only if the site has already accepted it. At least 4 characters.
</ParamField>

```json theme={null}
{
  "website": "shop.example.com",
  "email": "agent@example.com",
  "minLength": 12,
  "noSymbols": false
}
```

Responds `202` with `{ "requestId": "...", "status": "pending", "expiresAt": 1789000000000 }`.
A pending request expires 24 hours after it is filed.

When the site and email are on the pre-approved list, the same `202` carries
`"status": "approved"`. The login already exists, so poll the `requestId` right
away: the first answer carries the tokens. A pre-approved signup that cannot
complete falls back to `pending` and waits for the account holder. Handle both
statuses. Pre-approval completes at once only within two ceilings:

* **2 signups per listed site.** The count resets only when the account holder
  removes the site and adds it again, or saves it with a different email.
* **60 signups per account in a 24-hour window, by default.** The window starts
  with the first pre-approved signup and resets to zero 24 hours later.

Past either one, the request comes back `pending` instead of `approved`. The
other checks an approval runs, such as the key's allowed-logins list or a free
label for the site's name, also send it to `pending`. See
[Limits](/reference/limits#ceilings).

The 500-login ceiling works differently. If the account already holds 500
logins when you file the request, the call is refused at once with `403` and
`login_limit`, whether or not the site is pre-approved. No request is created
and the account holder is not asked. The ceiling is checked again at approval,
so a request filed below it can still stay `pending` if the account reaches 500
while it waits.

### `GET /v1/logins/requests/{id}`

Polls one signup request.

| `status` | Body also contains |
| - | - |
| `pending` | `expiresAt` |
| `approved` | `login`, the new login's [catalog entry](/reference/token-catalog), `passwordPolicy` and `passwordOwnerManaged` |
| `denied` | Nothing else |
| `expired` | Nothing else |

```json theme={null}
{
  "status": "approved",
  "login": {
    "label": "shop",
    "website": "shop.example.com",
    "hasEmail": true,
    "tokens": {
      "password": "av_login_shop_password_u7x2k",
      "email": "av_login_shop_email_u7x2k"
    }
  },
  "passwordPolicy": {
    "length": 24,
    "classes": ["lowercase", "uppercase", "digit", "symbol"],
    "symbolAlphabet": "!@#$*+-_=.?"
  },
  "passwordOwnerManaged": false
}
```

The poll never returns a password. `login` is exactly what `GET /v1/tokens` will
show for it.

`passwordPolicy` describes the password the vault generated at approval.
`passwordOwnerManaged` is `true` when the account holder has replaced that
password from their dashboard before you polled. Then:

* `passwordPolicy` describes a password that no longer exists.
* The login's password token resolves to the account holder's password, so fill
  it as normal.
* `POST /v1/logins/password` refuses this login with `not_rotatable`, for good.
  If the site rejects the password, tell the account holder instead.

`false`, or absent on an older deployment, means the generated password is still
in place and the policy describes it.

An approved login the account holder has since removed from this key's scope
answers `scope_denied` instead of `approved`.

### `POST /v1/logins/username`

Records the handle on a login that was opened with only an email.

<ParamField body="label" type="string" required>
  The login's label, for example `shop`.
</ParamField>

<ParamField body="username" type="string" required>
  The handle. At least 4 characters, with no control characters.
</ParamField>

Responds `200` with the login's updated [catalog entry](/reference/token-catalog)
under `login`:

```json theme={null}
{
  "login": {
    "label": "shop",
    "website": "shop.example.com",
    "hasEmail": true,
    "hasUsername": true,
    "tokens": {
      "username": "av_login_shop_username_u7x2k",
      "password": "av_login_shop_password_u7x2k",
      "email": "av_login_shop_email_u7x2k"
    }
  }
}
```

A handle is recorded once. Sending the exact same handle again succeeds with the
same envelope. A different handle is refused with `username_already_set`.

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 request, gets 404
`not_found`, the same answer as an unknown label. Re-sending the handle already
stored still succeeds after the seven days.

### `POST /v1/logins/password`

Mints a replacement password for a login an agent created. The mock tokens do not
change, so a token already in use resolves to the new password.

<ParamField body="label" type="string" required>
  The login's label.
</ParamField>

Plus any of the password rule fields above. Responds `200` with the login's
catalog entry under `login`, the rules the new password was generated under in
`passwordPolicy`, and `rotated: true`:

```json theme={null}
{
  "login": {
    "label": "shop",
    "website": "shop.example.com",
    "hasEmail": true,
    "tokens": {
      "password": "av_login_shop_password_u7x2k",
      "email": "av_login_shop_email_u7x2k"
    }
  },
  "passwordPolicy": {
    "length": 24,
    "classes": ["lowercase", "uppercase", "digit"]
  },
  "rotated": true
}
```

This example followed `"noSymbols": true`, so `passwordPolicy` has no
`symbolAlphabet`. The tokens are the same as before the rotation. Only the API
key that created the login can rotate it, only within seven days of the login
being created, and only while the account holder has not replaced its password.
Seven days is the default; a deployment can set a different window. Every other
case, including an unknown label, gets 409 `not_rotatable`.

### Errors

| Status | `code` | Meaning |
| - | - | - |
| 400 | `bad_request` | The body is not JSON |
| 400 | `invalid_website` | Not a registrable domain |
| 400 | `invalid_email` | Not a single address of 4 to 254 characters with one `@` and no whitespace |
| 400 | `invalid_username` | Under 4 characters, or contains control characters |
| 400 | `password_too_short` | The site's maximum is below the shortest password the vault generates |
| 400 | `invalid_policy` | The password rules cannot all be met: a `minLength` above 64 or above `maxLength`, a length that is not a whole number, or a `symbols` with none of the vault's symbols |
| 401 | `unauthorized`, `invalid_api_key` | See [Authentication](#authentication) |
| 403 | `signup_not_enabled` | This key does not have the signup permission |
| 403 | `login_limit` | The account holds 500 logins, the most an agent may add to. Every login counts, including ones the account holder added |
| 403 | `scope_denied` | The approved login has since been removed from this key's scope |
| 404 | `not_found` | `POST /v1/logins/username`: no such login, or not one this key may change. Includes a login another key created and one created more than seven days ago |
| 404 | `request_not_found` | No such signup request |
| 409 | `username_already_set` | This login already has a username |
| 409 | `not_rotatable` | This login's password cannot be rotated by this key: another key created it, it was created more than seven days ago, the account holder replaced its password, or no such login exists |
| 413 | `bad_request` | The body is over 4 KB |
| 429 | `too_many_pending` | Five requests are already awaiting the owner. No `Retry-After` |
| 429 | `daily_limit` | The account's 24-hour credential-change budget is spent. It resets 24 hours after the first change counted in the window. No `Retry-After` |
| 429 | `rate_limited` | Over 20 writes this minute. `Retry-After` in whole seconds |

Some checks run only when the account holder approves a signup, such as the
key's allowed-logins list being full or the site's name having no free label. A
failed check is shown to the account holder and never returned to you: the poll
keeps answering `pending`. If the account holder says they approved and the
poll still answers `pending`, the approval was blocked: they have to fix the
cause and approve again. Do not open a second request. See
[Signup and login writes](/sdk/error-codes#signup-and-login-writes).

### `POST /v1/logins` (retired)

Answers `410 Gone` with `signup_requires_approval`. Use
`POST /v1/logins/requests` instead.

***

## Account holder links

The dashboard's approval, security code, challenge and signup prompts are backed
by single-use links sent to the account holder. Each link's token is the only
credential, it expires on its own, and the agent never sees it. They are not an
integration surface for agents.

| Path | Methods | Backs |
| - | - | - |
| `/v1/approvals/{token}` | `GET`, and `POST .../decide` with `{ "decision": "approve" }` or `"deny"`. An approve can add `"at": "declared"` (the agent's total) or `"page"` (the default). `"declared"` is accepted only when the `GET` shows `offersDeclared: true`, and an `amount_mismatch` hold accepts only `"declared"`; otherwise the answer is `400` with `code` `declared_not_offered` or `declared_required`, and the request stays pending | A purchase held for approval |
| `/v1/signup-requests/{token}` | `GET`, `POST` with `{ "decision": "approve" }` or `"deny"` | A signup request |
| `/v1/cvv-requests/{token}` | `GET` | A live security code prompt |
| `/v1/challenge-requests/{token}` | `GET` | A challenge handoff |

Deleting an account removes every pending prompt, and a link for a deleted
account reads as not found.

Two further paths serve Agent Vault's own infrastructure and billing. Neither
accepts an API key.

## Worked example

Read the catalog, check the registry and contribute, with no SDK:

```bash theme={null}
#!/usr/bin/env bash
set -euo pipefail

API="$SELF_AGENT_PAY_API_URL"
KEY="$SELF_AGENT_PAY_API_KEY"
PRODUCT="https://shop.example.com/product/thing"
FINGERPRINT="$(python3 -c '
import sys, urllib.parse as u
p = u.urlsplit(sys.argv[1])
print(u.urlunsplit((p.scheme, p.netloc, p.path, "", "")))
' "$PRODUCT")"

# 1. Which tokens apply to this site?
curl -sG -H "Authorization: Bearer $KEY" \
  "$API/v1/tokens" --data-urlencode "url=$FINGERPRINT" | jq '.cards, .logins'

# 2. What is already known about it? No auth, stripped URL.
curl -sG "$API/v1/recipes" --data-urlencode "url=$FINGERPRINT" | jq

# 3. After the run, contribute.
SUB=$(curl -s -X POST "$API/v1/recipes" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d @recipe.json | jq -r '.id')

# 4. Poll the verdict. Do not resubmit.
curl -s -H "Authorization: Bearer $KEY" "$API/v1/recipes/submissions/$SUB" | jq
```

<Note>
  This fingerprint strips the query and fragment. It does not remove a session
  token embedded in the path, such as a Stripe `cs_...` checkout or a Shopify
  `/checkouts/...` path. For those hosts, send only the scheme and host.
</Note>

## Related

* [Token catalog](/reference/token-catalog)
* [Limits and timeouts](/reference/limits)
* [Error codes](/sdk/error-codes)


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