[ Approving An AgentID Sign-In ]
A relying party starts an OpenID Connect sign-in for an AgentMail inbox and parks the browser on a waiting page. The browser cannot finish it. Sign-in completes only when the agent that owns the inbox signs that page's request id with a P-256 private key already registered to the inbox, and posts the signature to the approval endpoint. No API key, password, or session token ever passes through the browser.
- Approval endpoint
- POST https://api.auth.agentid.com/v0/authorize/approve
- Authentication
- The signed assertion only. No Authorization header, no API key, no cookie.
- Request id (jti)
- The 22-character id the waiting page prints and its human hands you. It names one pending sign-in.
- Signature
- Compact JWS, ES256 (ECDSA P-256 with SHA-256)
- Protected header
- {"alg":"ES256","typ":"agentid-approval+jwt","kid":"<api_key_id>"}
- Signed payload
- {"jti":"<request id>","inbox_id":"<your inbox>"}
- Success
- 204 No Content
00Get an AgentMail API key, if you have none
Steps 01–03 need a bearer AgentMail API key; the approval in step 04 does not. If AGENTMAIL_API_KEY is already in your environment, skip this step. If you are approving a sign-in for an inbox someone else runs, ask them for the key rather than making a second account — a fresh sign-up creates a fresh organization, and its keys cannot reach their inboxes.
An agent can sign itself up. The call needs a human email that is not already registered with AgentMail, and it returns the key once — store it before doing anything else. It also returns the first inbox, which is the inbox the rest of this page registers a signing key for.
curl -sS -X POST https://api.agentmail.to/v0/agent/sign-up \
-H "Content-Type: application/json" \
-d '{ "human_email": "your-developer@example.com", "username": "your-agent-name" }'
# -> { "api_key": "am_us_...", "inbox_id": "...", "organization_id": "..." }
# An OTP goes to human_email. Confirm it with the key you were just handed:
curl -sS -X POST https://api.agentmail.to/v0/agent/verify \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "otp_code": "<code from that email>" }'If sign-up fails because the email is already registered, the account exists: have its owner generate a key at console.agentmail.to and hand it to you as AGENTMAIL_API_KEY.
01Check whether you already have a signing key
Registration is one time per agent. If you already hold a private JWK and its api_key_id, skip to step 04. To check what is registered for your organization:
curl -sS https://api.agentmail.to/v0/api-keys/public-keys \
-H "Authorization: Bearer $AGENTMAIL_API_KEY"02Generate a P-256 keypair
The private key stays in your keystore forever. It is never sent to AgentMail, never pasted into a browser, and never leaves the process that signs. Only the public coordinates are registered.
import { exportJWK, generateKeyPair } from 'jose'
const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true })
// Register exactly these four fields. Any extra member is rejected.
const { kty, crv, x, y } = await exportJWK(publicKey)
// Store this in your keystore or secret manager. Never transmit it.
const privateJwk = await exportJWK(privateKey)import json
from jwcrypto import jwk
key = jwk.JWK.generate(kty='EC', crv='P-256')
private_jwk = key.export_private() # keystore only
public = json.loads(key.export_public())
public_jwk = {k: public[k] for k in ('kty', 'crv', 'x', 'y')}openssl ecparam -name prime256v1 -genkey -noout -out agentid-private.pem
openssl ec -in agentid-private.pem -pubout -out agentid-public.pem
# PEM is not a JWK. Convert the public half before registering:
# node: await exportJWK(await importSPKI(pem, 'ES256'))
# python: jwk.JWK.from_pem(open('agentid-public.pem','rb').read())03Register the public key with AgentMail
A bearer AgentMail API key with the api_key_create permission authorizes registration and nothing else. Scope the key as narrowly as it will be used: inbox, pod, or organization. Omit scope to inherit the API key's own scope. The credential it creates carries no REST permissions, so it can only ever approve sign-ins for inboxes inside its scope.
curl -sS -X POST https://api.agentmail.to/v0/api-keys/public-keys \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "agentid-sign-in",
"scope": { "type": "inbox", "id": "agent@yourdomain.agentmail.to" },
"public_key": {
"kty": "EC",
"crv": "P-256",
"x": "<base64url>",
"y": "<base64url>"
}
}'The response returns api_key_id. That value is your kid. Store it next to the private key. You cannot recover it from the key material later, though you can list it again.
{
"api_key_id": "0f7a4c2e-...", <-- this is your kid
"type": "public_key",
"name": "agentid-sign-in",
"public_key": {
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
"fingerprint": "..."
},
"scope": { "type": "inbox", "id": "agent@yourdomain.agentmail.to" },
"created_at": "..."
}04Sign the request id and post the approval
Substitute the request id from the waiting page for the placeholder below. Sign a payload containing exactly two claims and post it with an unsigned copy of the inbox. The server compares the two copies byte for byte, so they must be identical strings.
import { importJWK, SignJWT } from 'jose'
const jti = '<request-id-from-page>'
const inboxId = 'agent@yourdomain.agentmail.to'
const kid = process.env.AGENTID_KEY_ID // the api_key_id from step 03
const privateJwk = JSON.parse(process.env.AGENTID_PRIVATE_JWK)
const key = await importJWK(privateJwk, 'ES256')
// Exactly these two claims. No iat, exp, aud, iss, or sub.
const assertion = await new SignJWT({ jti, inbox_id: inboxId })
.setProtectedHeader({ alg: 'ES256', typ: 'agentid-approval+jwt', kid })
.sign(key)
const response = await fetch('https://api.auth.agentid.com/v0/authorize/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assertion, inbox_id: inboxId }),
})
console.log(response.status) // 204 means approvedimport json, os, requests
from jwcrypto import jwk, jws
jti = '<request-id-from-page>'
inbox_id = 'agent@yourdomain.agentmail.to'
kid = os.environ['AGENTID_KEY_ID']
key = jwk.JWK(**json.loads(os.environ['AGENTID_PRIVATE_JWK']))
token = jws.JWS(json.dumps({'jti': jti, 'inbox_id': inbox_id}).encode())
token.add_signature(
key,
alg='ES256',
protected=json.dumps({'alg': 'ES256', 'typ': 'agentid-approval+jwt', 'kid': kid}),
)
response = requests.post(
'https://api.auth.agentid.com/v0/authorize/approve',
json={'assertion': token.serialize(compact=True), 'inbox_id': inbox_id},
)
print(response.status_code) # 204 means approvedIf you already have the compact JWS from a signing service, the raw call is all you need:
INBOX_ID="agent@yourdomain.agentmail.to"
curl -sS -i -X POST https://api.auth.agentid.com/v0/authorize/approve \
-H "Content-Type: application/json" \
-d "{\"assertion\":\"$ASSERTION\",\"inbox_id\":\"$INBOX_ID\"}"
# HTTP/1.1 204 No Content05Stop there
A 204 is the whole job. The waiting page is watching the transaction and will complete the redirect back to the application on its own. The approval call never returns an authorization code, redirect URI, or token, and there is nothing for the agent to forward anywhere. Do not retry a successful approval: the transaction is single use and moves straight to consumed.
Rules that will fail the call
- The payload must contain exactly
jtiandinbox_id. The server owns freshness, soiat,exp, andaudare rejected rather than ignored. - The header must carry
alg,typ, andkidonly. Embedded key material (jwk,jku,x5u,x5c) andcritare refused. - Compact serialization only. JSON JWS and unsecured tokens are refused, and the assertion is capped at 2048 bytes.
- The
inbox_idin the body must match the signed one exactly, including case. The server does not normalize before comparing. - The signed inbox must be live and inside the credential's scope. If the sign-in was started with a
login_hint, it must be that exact inbox. - Approve before the countdown on the waiting page runs out. After that the human has to start the sign-in again.
Reading the failures
- 401
- Signature, header, or credential problem: unknown kid, wrong alg or typ, bad signature, revoked or expired key, inbox outside the credential's scope, suspended organization. Deliberately undetailed, since the assertion is the authentication.
- 400
- The signature verified but the claims did not: extra or missing claims, a body inbox_id different from the signed one, or a login_hint mismatch.
- 404
- No pending transaction for this jti: unknown, already expired, or already approved or consumed. Start the sign-in again from the application.
A failed call leaves the transaction pending, so a corrected retry before expiry is safe.
Secured by AgentMail · OpenID Connect