AgentID Sign-In

Enroll a persistent browser as an inbox without sending an AgentMail API key to AgentID.

An inbox-scoped public key lets a persistent browser create and retain its own non-extractable P-256 credential for one inbox and sign in as that inbox at AgentID relying parties. It is an API key of type browser: it has no secret, and the bearer API key that created it bounds what it may do. That bearer key is sent only to the AgentMail API. The browser creates the private key and never exports it.

There are two ways to create one. If the browser is already on an AgentID sign-in page, authorize that page’s transaction for the inbox with Authorize Inbox and auth_token, as this guide shows. If the agent is choosing the browser itself, call Connect Provider and open the returned magic_url in it. Both paths produce the same public key.

US production availability

Browser sign-in is available to every organization using US production at https://api.agentmail.to. It is not available in EU production at https://api.agentmail.eu; these routes return 404 there.

Verify the browser origin before connecting

An agent that submits a auth_token read from any origin other than https://auth.agentid.com can be induced to enroll an attacker’s browser for its own inbox. Verify the final page origin through the browser or automation transport itself. The origin fields inside the action are self-asserted and are not sufficient on their own.

AgentMail API keys are sent only to https://api.agentmail.to; AgentID never requests them.

The AgentID configuration document publishes the same fixed origins and endpoints for discovery. It is not an origin override: do not accept a different origin from runtime input.

Attach flow

  1. Open an AgentID authorization transaction in a persistent browser.
  2. When the page reports that enrollment is required, capture its machine action and the browser’s independently reported final URL from the same page observation. Do not combine an action from one page or frame with the URL from another.
  3. Verify that the final URL’s origin is exactly https://auth.agentid.com. Also verify the action’s fixed fields and expiry.
  4. Select the inbox from trusted AgentMail configuration; never use login_hint as the source of authority. If the action publishes a string login_hint, require the selected inbox to match it. A null action hint does not prove that the original authorization transaction omitted its hint; the API still checks the original value and may return 400.
  5. Send {auth_token} with the bearer API key to exactly https://api.agentmail.to/v0/inboxes/{inbox_id}/authorize, the fixed endpoint the action publishes, with the selected inbox in the path. The response is the pending public key, read back at GET /v0/api-keys/{api_key_id}. Add accept_disclosure: true to accept the relying party’s disclosure on the agent’s behalf. The token is the idempotency anchor, so no Idempotency-Key is needed.
  6. Keep the existing AgentID page open. It observes the pending key, creates a non-extractable key, proves possession, and continues the same transaction.

Create API Key requires api_key_create. A new key returns 202; an idempotent retry for the same pending transaction, inbox, and bearer key returns 200 with the same key:

{
"api_key_id": "4d795cc3-ae87-4f84-85e3-4ad4ca656f44",
"type": "public_key",
"pod_id": "0b8e2f4a-9c1d-4e6f-b7a8-2d3e4f5a6b7c",
"inbox_id": "agent@example.com",
"status": "pending",
"permissions": {},
"created_by": { "api_key_id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f" },
"created_at": "2026-08-22T00:00:00.000Z",
"expires_at": "2026-08-22T00:05:00.000Z"
}

The response contains no magic_url, AgentID URL, token, cookie, or navigation instruction. Do not navigate away from the browser transaction in response to it. Poll Get API Key with the api_key_id to see status become active.

A pending key lasts at most five minutes and may expire sooner with its authorization transaction. An active one lasts at most 30 days. Remembered consent lasts 180 days. Treat every returned expires_at as authoritative and connect again before the current key expires.

Creation is limited to 20 pending keys per bearer API key per hour, 100 per organization per hour, and five live unactivated keys per bearer API key. A limit returns 429; honor its Retry-After header instead of retrying before the indicated time. Delete an unused pending key or let it expire to release a slot.

Browser activation is separately limited to 20 activations per authorizing bearer API key per UTC day. Hitting that limit can return 429 from the browser activation step after the connect call succeeded. Honor Retry-After and wait for the daily window to reset; deleting a pending key does not reset the activation counter.

Attach with HTTP

The browser page exposes a machine action with the following shape. Read it only after obtaining the final page URL from the browser transport:

{
"type": "agentid_session_required",
"issuer": "https://auth.agentid.com",
"browser_origin": "https://auth.agentid.com",
"agentmail_authorize_endpoint": "https://api.agentmail.to/v0/inboxes/{inbox_id}/authorize",
"auth_token": "AAAAAAAAAAAAAAAAAAAAAA",
"transaction_expires_at": 1787694600,
"login_hint": null,
"login_hint_authoritative": false,
"invariant": "AgentMail API keys are sent only to https://api.agentmail.to; AgentID never requests them."
}

login_hint is always present in the action but may be null. When it is a string, it is still non-authoritative and must match the trusted inbox selected by the caller. A null value means only that AgentID did not publish a safe-shaped hint; the server may still enforce a hint stored on the original authorization transaction.

These examples use ordinary authenticated HTTP. They validate the fixed endpoint from the action, then send an inbox selected from trusted AgentMail configuration. They never follow an arbitrary URL supplied at runtime.

import re
import time
from urllib.parse import urlsplit
import httpx
AGENTID_ORIGIN = "https://auth.agentid.com"
AUTHORIZE_ENDPOINT = "https://api.agentmail.to/v0/inboxes/{inbox_id}/authorize"
INVARIANT = (
"AgentMail API keys are sent only to https://api.agentmail.to; "
"AgentID never requests them."
)
JTI = re.compile(r"^[A-Za-z0-9_-]{22}$")
def canonical_origin(url: str) -> str:
parsed = urlsplit(url)
if parsed.scheme != "https" or parsed.hostname is None:
raise ValueError("Browser page is not HTTPS")
port = "" if parsed.port in (None, 443) else f":{parsed.port}"
return f"{parsed.scheme}://{parsed.hostname}{port}"
def create_browser_api_key(
*, source_url: str, action: dict, inbox_id: str, agentmail_api_key: str
) -> dict:
# Capture source_url and action together from the same browser page.
# source_url must come from the browser/automation transport, not the action.
if canonical_origin(source_url) != AGENTID_ORIGIN:
raise ValueError("Untrusted AgentID origin")
if (
action.get("type") != "agentid_session_required"
or action.get("issuer") != AGENTID_ORIGIN
or action.get("browser_origin") != AGENTID_ORIGIN
or action.get("agentmail_api_keys_endpoint") != AUTHORIZE_ENDPOINT
or action.get("login_hint_authoritative") is not False
or action.get("invariant") != INVARIANT
):
raise ValueError("Invalid AgentID enrollment action")
jti = action.get("auth_token")
expires_at = action.get("transaction_expires_at")
if not isinstance(jti, str) or JTI.fullmatch(jti) is None:
raise ValueError("Invalid auth_token")
if type(expires_at) is not int or expires_at <= int(time.time()):
raise ValueError("Expired AgentID enrollment action")
# inbox_id comes from trusted AgentMail configuration, not login_hint.
login_hint = action.get("login_hint")
if login_hint is not None and login_hint != inbox_id:
raise ValueError("Trusted inbox does not match login_hint")
response = httpx.post(
AUTHORIZE_ENDPOINT.replace("{inbox_id}", inbox_id),
headers={"Authorization": f"Bearer {agentmail_api_key}"},
json={"auth_token": jti},
follow_redirects=False,
timeout=10,
)
if response.status_code not in (200, 202):
raise RuntimeError(f"Create failed: {response.status_code}")
return response.json()

Manage credentials

Management requests use a bearer API key at https://api.agentmail.to. List operations require api_key_read; deletion and cancellation require api_key_delete.

OperationRequest
Attach a browser already on an AgentID pagePOST /v0/inboxes/{inbox_id}/authorize with auth_token
Enroll a browser by magic URLPOST /v0/providers/{provider_id}/connect
List every key, or only public keysGET /v0/api-keys or GET /v0/api-keys?type=public_key
List an inbox’s keysGET /v0/inboxes/{inbox_id}/api-keys
Get one, pending or activeGET /v0/api-keys/{api_key_id}
Cancel a pending key or revoke an active oneDELETE /v0/api-keys/{api_key_id}

The older /v0/api-keys/browser-credentials list, delete, and events endpoints, the browser-credentials/enrollments create and cancel endpoints, the /v0/api-keys/browser-consents list and delete endpoints, POST /v0/inboxes/{inbox_id}/api-keys with auth_token, and the /v0/api-keys/public-keys routes have been removed and answer 404; use the endpoints above.

The list endpoint accepts limit from 1 through 100 and a sealed page_token. It returns count, the effective limit, an optional next_page_token, and the api_keys array.

For rotation, connect and verify a new key, then delete the old one. There is no endpoint that extends a key in place.

A key a browser holds is independent of the bearer API key that created it. It carries exactly two permissions, provider_connect and provider_share_owner, snapshotted from that bearer key at creation and enforced from the key itself; change them with PATCH /v0/api-keys/{api_key_id}. Deleting or narrowing the bearer key afterward does not affect it.

provider_share_owner is one grouped grant covering both owner scopes. It is snapshotted as true only when the creating bearer key holds provider_share_owner, or both of the older owner_profile and owner_email grants; a bearer key holding just one of those mints keys with provider_share_owner: false, and can no longer pre-approve that single scope.

The browser’s Forget action deletes only local key material. It does not revoke the server-side key. Use DELETE /v0/api-keys/{api_key_id} for server-side revocation.

AgentID shows an explicit Allow/Deny review on first use, after a material change to the requested scopes, callback, or trust configuration, when remembered consent expires, and when the client sends prompt=consent. An exact unexpired consent can be reused. With prompt=none, a transaction that cannot reuse consent returns consent_required to the client instead of opening an interactive approval.

Remembered consent is not exposed through the API; it expires on its own and is re-reviewed after any material change to the relying party.

Persistent browser support

Use a standard persistent Chromium profile. It supports enrollment, signing after a full browser restart, and re-enrollment after site data is cleared.

Do not promise persistence for private or incognito sessions. Safari and Safari Technology Preview remain unqualified until real-browser enrollment, full restart, signing, and site-data-clear recovery are recorded. A successful short test does not override WebKit’s documented seven-day eviction behavior for script-writable storage. Playwright WebKit is not a shipping Safari qualification. Embedded WebViews must be qualified in the real host application with its configured persistent data store and a full host restart; nonpersistent data stores are unsupported.

Migration from public-key authentication

Inbox-scoped public keys are the current path for persistent browser approval. The AgentID public-key flow remains supported during migration, and no deprecation date is being announced with this release.