Skip to content

VeryAI is now Very.

Reference /

KYA API Reference

KYA API Reference

Base URL: https://api.very.org/kya (staging: https://api.stg.very.org/kya). Paths below are relative to it. The same API is published as an OpenAPI 3.1 spec at /openapi.yaml.

Conventions#

  • No API key. The endpoints are public and send Access-Control-Allow-Origin: *. Agents prove themselves with signatures; owners with a palm scan in the Very app.
  • JSON in snake_case. A request body is one JSON object of at most 64 KiB (413 request_too_large beyond). Unknown fields are ignored.
  • Encodings. public_key and signatures accept base64 or base64url. Hashes and token ids are base64url without padding (padding is tolerated).
  • Errors are {"error": "<code>", "message": "<text>"}. The code is stable; the message is for people. 400 invalid_request is a malformed request, 503 means KYA or Very could not answer, and 500 internal is unexpected.
  • Verdicts are not errors. actions/verify, signatures/verify and challenges/verify answer 200 for every refusal, with the reason in the body.
{
  "error": "agent_not_found",
  "message": "no agent with this device id"
}

Endpoints#

MethodPathWhoDescription
POST/v1/registrationsAgentRegister an agent (starts an approval)
POST/v1/approvalsAgentAsk the owner to approve one action (starts an approval)
GET/v1/approvals/{approval_id}Anyone with the idPoll an approval
GET/v1/agents/{device_id}AnyoneLook up an agent
GET/v1/keys/{kid}AnyoneLook up an agent by key id (Web Bot Auth keyid)
POST/v1/agents/{device_id}/revokeAgentRevoke itself, at once
POST/v1/agents/{device_id}/owner-revocationsAnyoneOwner revokes one agent or all (starts an approval)
POST/v1/actions/verifyRelying partyVerdict on an agent-signed action
POST/v1/signatures/verifyRelying partyVerdict on a plain signature by an agent
POST/v1/challengesAgentGet a reverse-CAPTCHA challenge
POST/v1/challenges/verifyAgentSubmit a solution
POST/v1/demo/solveKYA's demoHave the demo agent attempt a challenge
GET/.well-known/jwks.jsonAnyoneKYA's signing keys
GET/openapi.yamlAnyoneThis API as OpenAPI 3.1

POST /v1/registrations#

Register an agent. The agent proves it holds the private key with a kya-register:v1 proof, and KYA starts a registration approval. Whoever completes it in the Very app becomes the agent's owner. A key is single use: once registered, revoked or not, it can never be registered again.

Request body#

FieldTypeRequiredDescription
device_idstringYesThe agent's id, chosen by the agent: 1 to 128 characters of A-Z a-z 0-9 . _ ~ -, not only dots
public_keystringYesEd25519 public key, DER SubjectPublicKeyInfo, base64 or base64url
proofProofYesSigns kya-register:v1:<device_id>:<kid>:<timestamp_ms>, where kid is the key's RFC 7638 thumbprint
POST /kya/v1/registrations
Content-Type: application/json

{
  "device_id": "my-agent-01",
  "public_key": "MCowBQYDK2VwAyEA...",
  "proof": {
    "message": "kya-register:v1:my-agent-01:<kid>:1793803667000",
    "signature": "<base64url Ed25519 signature>",
    "timestamp": 1793803667000
  }
}

Response (201)#

An Approval of kind registration. It expires after 15 minutes.

{
  "approval_id": "9-ViRBp9vndJpqSilQbwILNR2Y5lG9rFNgHfalNdvYk",
  "kind": "registration",
  "status": "pending",
  "device_id": "my-agent-01",
  "expires_at": "2026-11-04T15:02:46.214189Z",
  "links": {
    "qr_url": "https://connect.very.org/download?action=oauth&clientId=...&key=...&requestId=...&sessionId=...",
    "app_link": "veros://scan/?action=oauth&clientId=...&key=...&requestId=...&sessionId=..."
  }
}

Errors#

StatusCodeMeaning
400invalid_device_id, invalid_public_key, invalid_proof, invalid_requestA field is missing or wrong, or the proof does not verify, is not fresh or names another device or key
409agent_existsThis device_id is already registered
409agent_revokedThis device_id belonged to a revoked agent; use a new id and a new key
409key_already_registeredThis key was registered before; keys are single use
503very_unavailable, unavailableThe Very app approval could not be started, or KYA could not answer; try again

POST /v1/approvals#

Ask the agent's owner to approve one action (step-up). Only the agent itself can start an action approval, with a kya-approve:v2 proof bound to what is approved. Very matches only the owner's palm. On completion, result.authorization is a kya-authorization+jwt bound to the agent, its owner, audience and action_hash. The agent sends it to the relying party with the action token, and the relying party passes it to actions/verify as human_authorization.

Request body#

FieldTypeRequiredDescription
device_idstringYesThe agent
audiencestringYesThe relying party's origin, as in the action token's aud. At most 512 bytes
action_hashstringYesbase64url of the 32-byte SHA-256 of the action, as in the action token
action_descriptionstringNoAt most 200 characters. Stored with the approval; shown to the owner once the Very app can display it
proofProofYesSigns kya-approve:v2:<device_id>:<kid>:<timestamp_ms>:<binding>

binding is base64url (no padding) of SHA-256 over u32be(len(audience)) || audience || action_hash_digest || u32be(len(action_description)) || action_description. Lengths are 4-byte big-endian byte counts, strings are UTF-8, action_hash_digest is the 32 raw digest bytes, and an absent description is empty. So the proof covers exactly what the owner is asked to approve. See the test vector.

POST /kya/v1/approvals
Content-Type: application/json

{
  "device_id": "my-agent-01",
  "audience": "https://shop.example.com",
  "action_hash": "<base64url SHA-256 of the action>",
  "action_description": "Order 1 x A-100",
  "proof": {
    "message": "kya-approve:v2:my-agent-01:<kid>:1793803667000:<binding>",
    "signature": "<base64url Ed25519 signature>",
    "timestamp": 1793803667000
  }
}

Response (201)#

An Approval of kind action. It expires after 5 minutes. When it completes, result.authorization is valid for 5 minutes and works once.

Errors#

StatusCodeMeaning
400invalid_requestaudience missing or too long, action_hash not a 32-byte base64url digest, or action_description too long
403invalid_proofThe proof does not verify, is not fresh, or covers another device, key, audience, action or description
403owner_suspendedThe owner is not in good standing with Very
404agent_not_foundNo agent with this device_id
409agent_revokedThe agent is revoked
503very_unavailable, unavailableThe Very app approval could not be started, or KYA or Very could not answer

GET /v1/approvals/{approval_id}#

Poll an approval about every 2 seconds until status is no longer pending. Polling is what completes it: while it is pending, a poll reads the Very app's answer and acts on it. The approval id is a bearer handle: anyone who holds it can read the result. Responses are Cache-Control: no-store.

Response (200)#

An Approval.

Errors#

StatusCodeMeaning
404approval_not_foundNo approval with this id
503unavailableKYA could not answer; poll again

GET /v1/agents/{device_id}#

An agent's public entry. This is a lookup, not a decision: if Very does not answer for the owner, owner_standing is unknown rather than an error.

Response (200)#

An Agent.

{
  "device_id": "my-agent-01",
  "kid": "<kid>",
  "public_key": "MCowBQYDK2VwAyEA...",
  "status": "active",
  "owner_id": "3f2c9a8e-5d41-4b7a-9c3e-1a2b3c4d5e6f",
  "owner_standing": "good",
  "registered_at": "2026-11-04T14:49:10.52Z"
}

Errors#

StatusCodeMeaning
404agent_not_foundNo agent with this device_id
503unavailableKYA could not answer

GET /v1/keys/{kid}#

The same entry, looked up by the agent's key id: the RFC 7638 JWK thumbprint of its key, base64url(SHA-256({"crv":"Ed25519","kty":"OKP","x":"<base64url key>"})). It is the keyid of the agent's Web Bot Auth signatures. Same response and errors as GET /v1/agents/{device_id}, with agent_not_found for an unknown key id.

POST /v1/agents/{device_id}/revoke#

The agent revokes itself, at once, with a proof signing kya-revoke:v1:<device_id>:<kid>:<timestamp_ms>. It is permanent: the key and the device_id can never be registered again. Repeating it returns the original revocation.

Request body#

FieldTypeRequiredDescription
proofProofYesA kya-revoke:v1 proof
reasonstringNoAt most 200 characters

Response (200)#

The revoked Agent, with status revoked, revoked_at and revoked_by. owner_standing is not looked up here and reads unknown.

Errors#

StatusCodeMeaning
400invalid_requestThe body is malformed or reason is too long
403invalid_proofThe proof does not verify, is not fresh, or names another device or key
404agent_not_foundNo agent with this device_id
503unavailableKYA could not answer

POST /v1/agents/{device_id}/owner-revocations#

Start an approval for the owner to revoke this agent (scope: "agent") or every agent they hold (scope: "all"). Anyone can start it, since the owner may have lost the agent; only the owner's palm completes it. Any other palm fails the approval with owner_mismatch. scope: "all" can be started from any of the owner's agents, revoked or not.

Request body#

FieldTypeRequiredDescription
scopestringYesagent or all
reasonstringNoAt most 200 characters

Response (201)#

An Approval of kind revoke_agent or revoke_all. It expires after 5 minutes. When it completes, result.revoked_device_ids lists the agents it revoked.

Errors#

StatusCodeMeaning
400invalid_requestscope is not agent or all, or reason is too long
404agent_not_foundNo agent with this device_id
409agent_revokedWith scope: "agent": the agent is already revoked
503very_unavailable, unavailableThe Very app approval could not be started, or KYA could not answer

POST /v1/actions/verify#

The relying party forwards the agent's action token with its own view of the request: expected_aud (its origin) and expected_action_hash (base64url(SHA-256(JCS(action))), recomputed from the request it received). KYA never takes either from the token. Keys come only from KYA's registry, by the token's iss and kid.

Request body#

FieldTypeRequiredDescription
action_tokenstringYesThe agent's kya-action+jwt (the Kya-Action header, by convention)
expected_audstringYesYour origin
expected_action_hashstringYesYour base64url SHA-256 of the action (32 bytes)
human_authorizationstringNoA kya-authorization+jwt from a completed action approval (the Kya-Authorization header, by convention)
require_human_authorizationbooleanNoRefuse the action unless a valid human authorization comes with it. Default false
POST /kya/v1/actions/verify
Content-Type: application/json

{
  "action_token": "<kya-action+jwt from the Kya-Action header>",
  "expected_aud": "https://shop.example.com",
  "expected_action_hash": "<your base64url SHA-256 of the action>",
  "human_authorization": "<kya-authorization+jwt from the Kya-Authorization header>",
  "require_human_authorization": true
}

The action token is a compact JWS of at most 4096 bytes:

  • Header: {"alg":"EdDSA","typ":"kya-action+jwt","kid":"<RFC 7638 thumbprint>"}. The jwk, jku, x5u, x5c, x5t, x5t#S256 and crit members are refused.
  • Claims: iss (the device id), aud (a string, or an array of exactly one), iat and exp (integer seconds, exp at most 300 s after iat; 60 s of skew allowed on iat only), optional nbf, jti (16 to 64 random bytes, base64url), action_hash, optional action_type.

Response (200)#

An ActionVerdict. Gate on valid. The jti, and any human authorization presented, are spent only when everything else passed. A human authorization found valid is spent even when you did not require one: it authorizes exactly one action.

{
  "valid": true,
  "signature_verified": true,
  "device_id": "my-agent-01",
  "kid": "<kid>",
  "agent_status": "active",
  "owner_id": "3f2c9a8e-5d41-4b7a-9c3e-1a2b3c4d5e6f",
  "owner_standing": "good",
  "aud": "https://shop.example.com",
  "action_hash": "<base64url>",
  "action_type": "order",
  "jti": "<base64url>",
  "replay": "fresh",
  "human_authorization": {
    "required": true,
    "present": true,
    "valid": true,
    "consumed": true,
    "device_match": true,
    "owner_match": true,
    "aud_match": true,
    "action_hash_match": true,
    "exp": 1793803967
  },
  "reason_codes": [],
  "verified_at": "2026-11-04T14:50:01.12Z",
  "receipt": "<kya-receipt+jwt>"
}

Errors#

StatusCodeMeaning
400invalid_requestA required field is missing, or expected_action_hash is not a 32-byte base64url digest
503verification_unavailableKYA or Very could not answer. KYA does not vouch: fail closed

POST /v1/signatures/verify#

Checks a detached Ed25519 signature over the UTF-8 bytes of message with the registered key of the agent named by device_id or kid, and that the agent is active and its owner in good standing. It binds nothing: no audience, no action, no replay check. The message and its freshness are yours: sign a nonce you chose. To gate an action, use actions/verify. Messages starting with kya- are KYA's own proofs and are refused (reserved_message).

Request body#

FieldTypeRequiredDescription
device_idstringOne ofThe agent's device id
kidstringOne ofThe agent's key id. Send exactly one of device_id and kid
messagestringYesAt most 16 KiB of UTF-8 (bytes, not characters)
signaturestringYesEd25519 signature, base64 or base64url
POST /kya/v1/signatures/verify
Content-Type: application/json

{
  "kid": "<kid>",
  "message": "login:3q2-7wQ8Zc1nHk0yS0pX2A",
  "signature": "<base64url Ed25519 signature>"
}

Response (200)#

A SignatureVerdict. Gate on valid. This one is for a key that is not registered:

{
  "valid": false,
  "signature_verified": false,
  "device_id": null,
  "kid": null,
  "agent_status": "unregistered",
  "owner_id": null,
  "owner_standing": "unknown",
  "reason_codes": ["agent_unregistered"],
  "verified_at": "2026-11-04T14:47:47.16Z"
}

Errors#

StatusCodeMeaning
400invalid_requestNot exactly one of device_id and kid, or message or signature missing, or message too long
503verification_unavailableKYA or Very could not answer: fail closed

POST /v1/challenges#

Get a reverse-CAPTCHA challenge. The body is optional: {"type": "byte_transform" | "constrained_gen" | "structured_extraction"}, or no type for a random one. Each challenge allows one attempt at challenges/verify.

TypeWhat it takesTime limit
byte_transformParse 2 to 4 instructions, apply them to 256 bytes, return the lowercase hex SHA-256 of the result30 s
constrained_genWrite text with an exact line count, total word count, and sum of the ASCII codes of each line's first character20 s
structured_extractionPick the authoritative values out of a document full of decoys; return them joined with | in the order of fields30 s

Response (200)#

FieldTypeDescription
challenge_idstringThe challenge's id
challenge_typestringOne of the three types
difficultystringmedium
payloadobjectbyte_transform: data (256 bytes, standard base64), instructions (strings). constrained_gen: topic, lines, ascii_target, word_count, difficulty. structured_extraction: document, fields (strings)
tokenstringOpaque. Send it back with the solution
expires_atintegerUnix seconds
time_limit_secsintegerSeconds from issue
{
  "challenge_id": "Jb8-NAN-j9pH95s8",
  "challenge_type": "constrained_gen",
  "difficulty": "medium",
  "payload": {
    "topic": "electric circuits",
    "lines": 3,
    "ascii_target": 341,
    "word_count": 15,
    "difficulty": "medium"
  },
  "token": "<opaque>",
  "expires_at": 1793803697,
  "time_limit_secs": 20
}

Errors: 400 invalid_request for an unknown type, 503 unavailable.

POST /v1/challenges/verify#

Submit a solution: {"token": "<from the challenge>", "solution": "<string>"}. On success, capability_token is a kya-capability+jwt valid for one hour. It says a KYA challenge was solved solve_time_ms after it was issued, faster than a person could: automation, not necessarily a language model. It names no agent and no owner. A relying party verifies it offline with the JWKS, and may keep its jti until exp to accept each token once.

Response (200)#

FieldTypeDescription
successbooleanWhether the solution was right, in time, on the first attempt
challenge_typestringThe challenge's type, when the token could be read
solve_time_msintegerFrom issue to this verification (on success)
capability_tokenstringA kya-capability+jwt (on success)
reasonstringOn failure: challenge_invalid (not a KYA challenge token, or altered), challenge_expired, challenge_replayed (already attempted) or incorrect_solution
{
  "success": true,
  "challenge_type": "byte_transform",
  "solve_time_ms": 109,
  "capability_token": "<kya-capability+jwt>"
}

Errors: 400 invalid_request when token or solution is missing, 503 unavailable.

POST /v1/demo/solve#

Used by KYA's demo: the demo's agent (a language model) attempts one of KYA's challenges, sent back exactly as issued, as {"token": "...", "payload": {...}}. It returns the model's answer, unverified; submit it to challenges/verify like any agent. One attempt per challenge, and rate limited.

FieldTypeDescription
answeredbooleanWhether the model produced an answer
solutionstringThe model's answer, when answered
solverstringThe model, for example openrouter:google/gemini-2.5-flash
solve_time_msintegerHow long the model took
reasonstringsolver_failed when the model did not answer

Errors: 400 challenge_invalid or challenge_expired, 409 already_attempted, 429 rate_limited, 503 demo_solver_disabled or unavailable.

GET /.well-known/jwks.json#

KYA's Ed25519 signing keys, for verifying the tokens KYA issues. Served at https://api.very.org/kya/.well-known/jwks.json and cached for up to 5 minutes. Select the key by the token's kid.

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "mLp3FNp00Q_jxWUmwRvBS934T8ICZuZfzB2_jkQ0Sps",
      "kid": "o7ngBw4L5qoC04SCAXAbN9fe8cS85DHTYdBN8anlVM4",
      "use": "sig",
      "alg": "EdDSA"
    }
  ]
}

Objects#

Proof#

FieldTypeDescription
messagestring<purpose>:<device_id>:<kid>:<timestamp_ms>, where purpose is kya-register:v1 or kya-revoke:v1. Action approvals use kya-approve:v2 and append :<binding>
signaturestringEd25519 signature over the UTF-8 bytes of message, base64 or base64url
timestampintegerUnix milliseconds, the same as in message; within 5 minutes of KYA's clock

Approval#

FieldTypeDescription
approval_idstringBearer handle for polling
kindstringregistration, action, revoke_agent or revoke_all
statusstringpending, completed, failed or expired
failure_reasonstringWhen failed, for example access_denied (refused in the Very app), owner_mismatch, owner_suspended, standing_unavailable, agent_exists, agent_revoked, exchange_failed, approval_lost or completion_failed
device_idstringThe agent
expires_atdate-timeWhen the approval expires
linksobjectWhile pending: qr_url (show it as a QR code; the owner scans it with their phone) and app_link (opens the Very app directly, on the phone that has it)
resultobjectWhen completed; depends on kind, below
// kind: registration
"result": {
  "agent": {
    "device_id": "my-agent-01",
    "kid": "<kid>",
    "owner_id": "3f2c9a8e-5d41-4b7a-9c3e-1a2b3c4d5e6f",
    "registered_at": "2026-11-04T14:49:10.52Z"
  }
}

// kind: action
"result": {
  "authorization": "<kya-authorization+jwt>",
  "authorization_expires_at": "2026-11-04T14:54:10.52Z"
}

// kind: revoke_agent or revoke_all
"result": {
  "revoked_device_ids": ["my-agent-01"]
}

Agent#

FieldTypeDescription
device_idstringThe agent's id
kidstringRFC 7638 thumbprint of the agent's key
public_keystringThe key, DER SubjectPublicKeyInfo, standard base64
statusstringactive or revoked
owner_idstring (uuid)KYA's id for the owner. Never a Very user id
owner_standingstringgood; suspended when Very rejected, restricted or deleted the owner, or the owner disconnected KYA; unknown when Very did not answer
registered_atdate-timeWhen the registration completed
revoked_atdate-timeWhen revoked (revoked agents only)
revoked_bystringagent, owner or admin (revoked agents only)

ActionVerdict#

FieldTypeDescription
validbooleanGate on this. True only when every check passed and the jti was fresh
signature_verifiedbooleanThe signature verifies and the token's structure and times are valid (the audience and action bindings are reported separately)
device_idstring or nullThe agent, when the token named a registered one
kidstring or nullThe token's kid
agent_statusstringactive, revoked or unregistered
owner_idstring or nullKYA's id for the owner
owner_standingstringgood, suspended or unknown
aud, action_hash, action_type, jtistring or nullFrom the token, once its signature verified
replaystringfresh (spent now), replayed (accepted before) or unchecked (another check failed first)
human_authorizationobjectrequired, present, valid (boolean or null), consumed, device_match, owner_match, aud_match, action_hash_match (boolean or null) and exp (Unix seconds or null)
reason_codesstring[]Why it is not valid, sorted; empty when valid. See reason codes
verified_atdate-timeWhen KYA decided
receiptstringWhen valid: a kya-receipt+jwt valid for 5 minutes. Evidence, not a credential

SignatureVerdict#

valid, signature_verified, device_id, kid, agent_status, owner_id, owner_standing, reason_codes and verified_at, as in ActionVerdict. valid is true when the signature verifies, the agent is active and its owner is in good standing.

Reason codes#

The set is closed: you can switch on them.

CodeMeaning
malformed_tokenNot a compact JWS of three base64url parts within 4096 bytes, or iat/exp missing, not integers, or out of order
alg_not_allowedThe header alg is not EdDSA
typ_mismatchThe header typ is not kya-action+jwt
header_forbidden_memberThe header carries jwk, jku, x5u, x5c, x5t, x5t#S256 or crit
kid_missingNo kid in the header
kid_unknownNo agent with this iss, or its key has another kid
signature_invalidThe signature does not verify with the registered key
token_expiredexp has passed
token_not_yet_validiat is more than 60 s ahead, or nbf is in the future
ttl_too_longexp is more than 300 s after iat
jti_missing, jti_invalidNo jti, or not base64url of 16 to 64 bytes
jti_replayedThis jti was accepted before
aud_missing, aud_mismatchNo aud, or not your expected_aud
action_hash_missing, action_hash_invalid, action_hash_mismatchNo action_hash, not a 32-byte base64url digest, or not your expected_action_hash
key_revoked, agent_revokedThe agent is revoked (one key per agent, so both appear)
agent_unregisteredsignatures/verify: no agent with this device_id or kid
owner_suspendedThe owner is not in good standing with Very
human_authorization_requiredYou required one and none was sent
human_authorization_invalidNot a KYA human authorization: wrong format, typ, key, signature or issuer
human_authorization_expiredIts exp has passed
human_authorization_aud_mismatch, human_authorization_action_mismatch, human_authorization_device_mismatch, human_authorization_owner_mismatchIt was issued for another audience, action, agent or owner
human_authorization_replayedIt was spent before
reserved_messagesignatures/verify: the message starts with kya-

Tokens KYA issues#

Signed with EdDSA by the key in the JWKS, with header {"alg":"EdDSA","typ":"<typ>","kid":"<KYA key id>"}. iss is the API base URL: https://api.very.org/kya (staging https://api.stg.very.org/kya). Check typ: one kind must never be accepted as another.

typLifetimeClaims
kya-authorization+jwt5 minutes, single useiss, aud, owner_id, device_id, action_hash, verification_method (very_palm), iat, exp, jti. Pass it to actions/verify, which checks and spends it; checking it yourself does not spend it
kya-receipt+jwt5 minutesiss, aud, iat, exp, jti, device_id, kid, owner_id, action_hash, action_jti, valid, human_authorization_verified, reason_codes. Evidence of a verdict, never a credential
kya-capability+jwt1 houriss, sub (agent_capability_attestation), challenge_type, difficulty, solved_at, solve_time_ms, iat, exp, jti. Software solved a KYA challenge in time; it names no agent or owner