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_largebeyond). Unknown fields are ignored. - Encodings.
public_keyand 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_requestis a malformed request,503means KYA or Very could not answer, and500 internalis unexpected. - Verdicts are not errors.
actions/verify,signatures/verifyandchallenges/verifyanswer200for every refusal, with the reason in the body.
{
"error": "agent_not_found",
"message": "no agent with this device id"
} Endpoints#
| Method | Path | Who | Description |
|---|---|---|---|
POST | /v1/registrations | Agent | Register an agent (starts an approval) |
POST | /v1/approvals | Agent | Ask the owner to approve one action (starts an approval) |
GET | /v1/approvals/{approval_id} | Anyone with the id | Poll an approval |
GET | /v1/agents/{device_id} | Anyone | Look up an agent |
GET | /v1/keys/{kid} | Anyone | Look up an agent by key id (Web Bot Auth keyid) |
POST | /v1/agents/{device_id}/revoke | Agent | Revoke itself, at once |
POST | /v1/agents/{device_id}/owner-revocations | Anyone | Owner revokes one agent or all (starts an approval) |
POST | /v1/actions/verify | Relying party | Verdict on an agent-signed action |
POST | /v1/signatures/verify | Relying party | Verdict on a plain signature by an agent |
POST | /v1/challenges | Agent | Get a reverse-CAPTCHA challenge |
POST | /v1/challenges/verify | Agent | Submit a solution |
POST | /v1/demo/solve | KYA's demo | Have the demo agent attempt a challenge |
GET | /.well-known/jwks.json | Anyone | KYA's signing keys |
GET | /openapi.yaml | Anyone | This 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#
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | Yes | The agent's id, chosen by the agent: 1 to 128 characters of A-Z a-z 0-9 . _ ~ -, not only dots |
public_key | string | Yes | Ed25519 public key, DER SubjectPublicKeyInfo, base64 or base64url |
proof | Proof | Yes | Signs 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#
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_device_id, invalid_public_key, invalid_proof, invalid_request | A field is missing or wrong, or the proof does not verify, is not fresh or names another device or key |
| 409 | agent_exists | This device_id is already registered |
| 409 | agent_revoked | This device_id belonged to a revoked agent; use a new id and a new key |
| 409 | key_already_registered | This key was registered before; keys are single use |
| 503 | very_unavailable, unavailable | The 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#
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | Yes | The agent |
audience | string | Yes | The relying party's origin, as in the action token's aud. At most 512 bytes |
action_hash | string | Yes | base64url of the 32-byte SHA-256 of the action, as in the action token |
action_description | string | No | At most 200 characters. Stored with the approval; shown to the owner once the Very app can display it |
proof | Proof | Yes | Signs 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#
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | audience missing or too long, action_hash not a 32-byte base64url digest, or action_description too long |
| 403 | invalid_proof | The proof does not verify, is not fresh, or covers another device, key, audience, action or description |
| 403 | owner_suspended | The owner is not in good standing with Very |
| 404 | agent_not_found | No agent with this device_id |
| 409 | agent_revoked | The agent is revoked |
| 503 | very_unavailable, unavailable | The 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#
| Status | Code | Meaning |
|---|---|---|
| 404 | approval_not_found | No approval with this id |
| 503 | unavailable | KYA 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#
| Status | Code | Meaning |
|---|---|---|
| 404 | agent_not_found | No agent with this device_id |
| 503 | unavailable | KYA 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#
| Field | Type | Required | Description |
|---|---|---|---|
proof | Proof | Yes | A kya-revoke:v1 proof |
reason | string | No | At 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#
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The body is malformed or reason is too long |
| 403 | invalid_proof | The proof does not verify, is not fresh, or names another device or key |
| 404 | agent_not_found | No agent with this device_id |
| 503 | unavailable | KYA 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#
| Field | Type | Required | Description |
|---|---|---|---|
scope | string | Yes | agent or all |
reason | string | No | At 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#
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | scope is not agent or all, or reason is too long |
| 404 | agent_not_found | No agent with this device_id |
| 409 | agent_revoked | With scope: "agent": the agent is already revoked |
| 503 | very_unavailable, unavailable | The 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#
| Field | Type | Required | Description |
|---|---|---|---|
action_token | string | Yes | The agent's kya-action+jwt (the Kya-Action header, by convention) |
expected_aud | string | Yes | Your origin |
expected_action_hash | string | Yes | Your base64url SHA-256 of the action (32 bytes) |
human_authorization | string | No | A kya-authorization+jwt from a completed action approval (the Kya-Authorization header, by convention) |
require_human_authorization | boolean | No | Refuse 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>"}. Thejwk,jku,x5u,x5c,x5t,x5t#S256andcritmembers are refused. - Claims:
iss(the device id),aud(a string, or an array of exactly one),iatandexp(integer seconds,expat most 300 s afteriat; 60 s of skew allowed oniatonly), optionalnbf,jti(16 to 64 random bytes, base64url),action_hash, optionalaction_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#
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A required field is missing, or expected_action_hash is not a 32-byte base64url digest |
| 503 | verification_unavailable | KYA 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#
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | One of | The agent's device id |
kid | string | One of | The agent's key id. Send exactly one of device_id and kid |
message | string | Yes | At most 16 KiB of UTF-8 (bytes, not characters) |
signature | string | Yes | Ed25519 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#
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Not exactly one of device_id and kid, or message or signature missing, or message too long |
| 503 | verification_unavailable | KYA 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.
| Type | What it takes | Time limit |
|---|---|---|
byte_transform | Parse 2 to 4 instructions, apply them to 256 bytes, return the lowercase hex SHA-256 of the result | 30 s |
constrained_gen | Write text with an exact line count, total word count, and sum of the ASCII codes of each line's first character | 20 s |
structured_extraction | Pick the authoritative values out of a document full of decoys; return them joined with | in the order of fields | 30 s |
Response (200)#
| Field | Type | Description |
|---|---|---|
challenge_id | string | The challenge's id |
challenge_type | string | One of the three types |
difficulty | string | medium |
payload | object | byte_transform: data (256 bytes, standard base64), instructions (strings). constrained_gen: topic, lines, ascii_target, word_count, difficulty. structured_extraction: document, fields (strings) |
token | string | Opaque. Send it back with the solution |
expires_at | integer | Unix seconds |
time_limit_secs | integer | Seconds 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)#
| Field | Type | Description |
|---|---|---|
success | boolean | Whether the solution was right, in time, on the first attempt |
challenge_type | string | The challenge's type, when the token could be read |
solve_time_ms | integer | From issue to this verification (on success) |
capability_token | string | A kya-capability+jwt (on success) |
reason | string | On 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.
| Field | Type | Description |
|---|---|---|
answered | boolean | Whether the model produced an answer |
solution | string | The model's answer, when answered |
solver | string | The model, for example openrouter:google/gemini-2.5-flash |
solve_time_ms | integer | How long the model took |
reason | string | solver_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#
| Field | Type | Description |
|---|---|---|
message | string | <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> |
signature | string | Ed25519 signature over the UTF-8 bytes of message, base64 or base64url |
timestamp | integer | Unix milliseconds, the same as in message; within 5 minutes of KYA's clock |
Approval#
| Field | Type | Description |
|---|---|---|
approval_id | string | Bearer handle for polling |
kind | string | registration, action, revoke_agent or revoke_all |
status | string | pending, completed, failed or expired |
failure_reason | string | When 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_id | string | The agent |
expires_at | date-time | When the approval expires |
links | object | While 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) |
result | object | When 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#
| Field | Type | Description |
|---|---|---|
device_id | string | The agent's id |
kid | string | RFC 7638 thumbprint of the agent's key |
public_key | string | The key, DER SubjectPublicKeyInfo, standard base64 |
status | string | active or revoked |
owner_id | string (uuid) | KYA's id for the owner. Never a Very user id |
owner_standing | string | good; suspended when Very rejected, restricted or deleted the owner, or the owner disconnected KYA; unknown when Very did not answer |
registered_at | date-time | When the registration completed |
revoked_at | date-time | When revoked (revoked agents only) |
revoked_by | string | agent, owner or admin (revoked agents only) |
ActionVerdict#
| Field | Type | Description |
|---|---|---|
valid | boolean | Gate on this. True only when every check passed and the jti was fresh |
signature_verified | boolean | The signature verifies and the token's structure and times are valid (the audience and action bindings are reported separately) |
device_id | string or null | The agent, when the token named a registered one |
kid | string or null | The token's kid |
agent_status | string | active, revoked or unregistered |
owner_id | string or null | KYA's id for the owner |
owner_standing | string | good, suspended or unknown |
aud, action_hash, action_type, jti | string or null | From the token, once its signature verified |
replay | string | fresh (spent now), replayed (accepted before) or unchecked (another check failed first) |
human_authorization | object | required, 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_codes | string[] | Why it is not valid, sorted; empty when valid. See reason codes |
verified_at | date-time | When KYA decided |
receipt | string | When 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.
| Code | Meaning |
|---|---|
malformed_token | Not a compact JWS of three base64url parts within 4096 bytes, or iat/exp missing, not integers, or out of order |
alg_not_allowed | The header alg is not EdDSA |
typ_mismatch | The header typ is not kya-action+jwt |
header_forbidden_member | The header carries jwk, jku, x5u, x5c, x5t, x5t#S256 or crit |
kid_missing | No kid in the header |
kid_unknown | No agent with this iss, or its key has another kid |
signature_invalid | The signature does not verify with the registered key |
token_expired | exp has passed |
token_not_yet_valid | iat is more than 60 s ahead, or nbf is in the future |
ttl_too_long | exp is more than 300 s after iat |
jti_missing, jti_invalid | No jti, or not base64url of 16 to 64 bytes |
jti_replayed | This jti was accepted before |
aud_missing, aud_mismatch | No aud, or not your expected_aud |
action_hash_missing, action_hash_invalid, action_hash_mismatch | No action_hash, not a 32-byte base64url digest, or not your expected_action_hash |
key_revoked, agent_revoked | The agent is revoked (one key per agent, so both appear) |
agent_unregistered | signatures/verify: no agent with this device_id or kid |
owner_suspended | The owner is not in good standing with Very |
human_authorization_required | You required one and none was sent |
human_authorization_invalid | Not a KYA human authorization: wrong format, typ, key, signature or issuer |
human_authorization_expired | Its exp has passed |
human_authorization_aud_mismatch, human_authorization_action_mismatch, human_authorization_device_mismatch, human_authorization_owner_mismatch | It was issued for another audience, action, agent or owner |
human_authorization_replayed | It was spent before |
reserved_message | signatures/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.
| typ | Lifetime | Claims |
|---|---|---|
kya-authorization+jwt | 5 minutes, single use | iss, 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+jwt | 5 minutes | iss, 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+jwt | 1 hour | iss, 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 |