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"
ComponentValue
@challengechallenge_id
@noncenonce
@site-keysite_key
@originorigin
@issued-atissued_at as a decimal integer
@expires-atexpires_at as a decimal integer
@actionaction (may be empty)
@intentintent (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"
ParameterValueNotes
createdintegerWhen the agent produced the signature (unix seconds). Must be within ±60 s of the server clock and ≥ issued_at − 60.
keyidkey_…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
}
FieldTypeDescription
challenge_idrequiredstringMust match the challenge the proof is for (CHALLENGE_MISMATCH otherwise).
agent_idrequiredstringRegistered agent id.
key_idrequiredstringA key belonging to that agent. Must be active, or pending and bound to this registration challenge.
signaturerequiredstringbase64url (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.
createdrequiredintegerThe 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"
}
FieldTypeDescription
verifiedbooleantrue on success. Failures are returned as error responses, not as verified: false.
statusChallengeStatusVERIFIED.
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.
verificationobjectmethod, verified_at (ISO), challenge_id, origin, action, intent.
tokenstring | nullEdDSA JWT for the host. null for registration challenges.
expires_atstring | nullToken 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