Protocol
Signatures
The deterministic signature base an agent signs, its RFC 9421-style layout, the parameters line, and the proof body sent to /verify.
Signature base
The base is a newline-separated list of "name": value lines, one per covered component in fixed order, followed by a final "@signature-params" line. The layout mirrors the RFC 9421 signature base so the same mental model applies to HTTP Message Signatures. There is no trailing newline. The server rebuilds this string from its own stored challenge, so any client-side alteration of a field simply produces a mismatch.
Signature base (worked example)
"@challenge": ch_mfr4q2xk7tbn3ycz5w6ah2ep4d
"@nonce": aW1ub3RhaHVtYW4tZG9jcy1leGFtcGxlLW5vbmNlISE
"@site-key": site_live_exampleweb2ab3cd4ef5gh6j
"@origin": https://example.com
"@issued-at": 1789905600
"@expires-at": 1789905720
"@action": checkout
"@intent": purchase
"@mode": live
"@signature-params": ("@challenge" "@nonce" "@site-key" "@origin" "@issued-at" "@expires-at" "@action" "@intent" "@mode");created=1789905607;keyid="key_b7n2xq4mfc3rtw6y";alg="ed25519";tag="imnotahuman-challenge-v1"| Component | Value |
|---|---|
@challenge | challenge_id |
@nonce | nonce |
@site-key | site_key |
@origin | origin |
@issued-at | issued_at as a decimal integer |
@expires-at | expires_at as a decimal integer |
@action | action (may be empty) |
@intent | intent (may be empty) |
@mode | "live" or "test" |
Values containing CR, LF or NUL are rejected; the API never issues such values.
@signature-params
Format
("@challenge" "@nonce" "@site-key" "@origin" "@issued-at" "@expires-at" "@action" "@intent" "@mode");created=<unix seconds>;keyid="<key_id>";alg="ed25519";tag="imnotahuman-challenge-v1"| Parameter | Value | Notes |
|---|---|---|
created | integer | When the agent produced the signature (unix seconds). Must be within ±60 s of the server clock and ≥ issued_at − 60. |
keyid | key_… | The registered key id. Also sent as key_id in the proof body; the server uses its own record of the key to rebuild the line. |
alg | "ed25519" | Fixed. |
tag | "imnotahuman-challenge-v1" | Fixed. Domain-separates challenge signatures from any other use of the same key. |
Proof body
POST /api/v1/verify
{
"challenge_id": "ch_mfr4q2xk7tbn3ycz5w6ah2ep4d",
"agent_id": "agent_nova_8f2a",
"key_id": "key_b7n2xq4mfc3rtw6y",
"signature": "0IKKR9d3yZ4MIdTuKMNO99q-fF8kC5kp1Vak61hrQb1TEBhlzCDtkZxq7Ho1m5IjetPefqSAbrkDrHnueQhlCw",
"created": 1789905607
}| Field | Type | Description |
|---|---|---|
challenge_idrequired | string | Must match the challenge the proof is for (CHALLENGE_MISMATCH otherwise). |
agent_idrequired | string | Registered agent id. |
key_idrequired | string | A key belonging to that agent. Must be active, or pending and bound to this registration challenge. |
signaturerequired | string | base64url (no padding) of the 64-byte Ed25519 signature over the UTF-8 signature base. 86 characters; trailing = padding is tolerated, the standard base64 alphabet (+ and /) is not. |
createdrequired | integer | The same value used in @signature-params. |
The body is strict: unknown fields fail validation with SIGNATURE_MALFORMED.
VerificationResult
200 OK
{
"verified": true,
"status": "VERIFIED",
"subject_type": "agent",
"mode": "live",
"agent": { "id": "agent_nova_8f2a", "name": "Nova" },
"verification": {
"method": "ed25519_challenge",
"verified_at": "2026-09-20T12:00:07.412Z",
"challenge_id": "ch_mfr4q2xk7tbn3ycz5w6ah2ep4d",
"origin": "https://example.com",
"action": "checkout",
"intent": "purchase"
},
"token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImluaC1...",
"expires_at": "2026-09-20T12:10:07.000Z"
}| Field | Type | Description |
|---|---|---|
verified | boolean | true on success. Failures are returned as error responses, not as verified: false. |
status | ChallengeStatus | VERIFIED. |
subject_type | "agent" | The only subject type in v1. |
mode | "live" | "test" | From the challenge. |
agent | { id, name } | Registered agent, the Web Bot Auth origin identity, or the simulated agent in test mode. |
verification | object | method, verified_at (ISO), challenge_id, origin, action, intent. |
token | string | null | EdDSA JWT for the host. null for registration challenges. |
expires_at | string | null | Token expiry, ISO 8601. |
Verifying the worked example
check.mjs
import { createPublicKey, verify } from "node:crypto";
const x = "_Gqb_W0_oiqrRCCw8P_X8DSeNSwTiMpA4rUS3OqqBZk";
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });
const base = "\"@challenge\": ch_mfr4q2xk7tbn3ycz5w6ah2ep4d\n\"@nonce\": aW1ub3RhaHVtYW4tZG9jcy1leGFtcGxlLW5vbmNlISE\n\"@site-key\": site_live_exampleweb2ab3cd4ef5gh6j\n\"@origin\": https://example.com\n\"@issued-at\": 1789905600\n\"@expires-at\": 1789905720\n\"@action\": checkout\n\"@intent\": purchase\n\"@mode\": live\n\"@signature-params\": (\"@challenge\" \"@nonce\" \"@site-key\" \"@origin\" \"@issued-at\" \"@expires-at\" \"@action\" \"@intent\" \"@mode\");created=1789905607;keyid=\"key_b7n2xq4mfc3rtw6y\";alg=\"ed25519\";tag=\"imnotahuman-challenge-v1\"";
const signature = Buffer.from("0IKKR9d3yZ4MIdTuKMNO99q-fF8kC5kp1Vak61hrQb1TEBhlzCDtkZxq7Ho1m5IjetPefqSAbrkDrHnueQhlCw", "base64url");
console.log(verify(null, Buffer.from(base, "utf8"), key, signature)); // true