Integration Guide
Integration Guide
This guide covers both sides. The agent creates a key, registers it under its owner, signs its actions, asks its owner to approve an action when needed, and can revoke itself. The relying party verifies those actions, looks up Web Bot Auth keys, verifies plain signatures, and can start an owner revocation. See the Introduction for the model and the API Reference for every field.
Before you start#
- Base URL:
https://api.very.org/kyain production,https://api.stg.very.org/kyain staging. Paths below are relative to it. - An owner: a person with the Very app who has verified with their palm.
- The samples are TypeScript for Node.js, using
node:cryptoandjose(npm install jose). The agent samples build on each other: run them in order in one ES module.
Agent side#
1. Create the agent's key#
import { createHash, generateKeyPairSync, randomBytes, sign, type KeyObject } from "node:crypto";
import { SignJWT } from "jose";
// The agent's key. Store privateKey safely: it never leaves the agent.
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
// public_key: the key as a DER SubjectPublicKeyInfo, base64.
const publicKeySpki = publicKey.export({ type: "spki", format: "der" }).toString("base64");
// kid: the key's RFC 7638 JWK thumbprint.
function keyId(key: KeyObject): string {
const { x } = key.export({ format: "jwk" });
return createHash("sha256")
.update(`{"crv":"Ed25519","kty":"OKP","x":"${x}"}`)
.digest("base64url");
}
const kid = keyId(publicKey);
const deviceId = "my-agent-01"; // yours to choose: 1-128 of A-Z a-z 0-9 . _ ~ - public_keyis the Ed25519 public key as a DER SubjectPublicKeyInfo, in base64 or base64url.kidis the RFC 7638 thumbprint: base64url (no padding) of the SHA-256 of{"crv":"Ed25519","kty":"OKP","x":"<x>"}, with the members in that order and no whitespace. KYA computes it from your public key; you need it to sign proofs and action tokens.device_idis your agent's name in KYA: 1 to 128 characters fromA-Z a-z 0-9 . _ ~ -, not only dots.- Keep the private key. Save it (for example
privateKey.export({ type: "pkcs8", format: "pem" })) in your agent's secret store. A key can be registered only once, so a lost key means a newdevice_id, a new key and a new registration.
Check your kid code with the RFC 8037 test key:
| Input | Expected |
|---|---|
x = 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo | kid = kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k |
2. Sign proofs#
Registering, asking for an action approval and revoking each take a proof: a message the agent signs with its key. KYA rebuilds the expected message from the device id, the key's kid and your timestamp, and it must match byte for byte.
// The message an agent signs: <purpose>:<device_id>:<kid>:<timestamp_ms>,
// plus :<binding> for an action approval.
function proofMessage(purpose: string, deviceId: string, kid: string, timestampMs: number, binding?: string): string {
const message = `${purpose}:${deviceId}:${kid}:${timestampMs}`;
return binding === undefined ? message : `${message}:${binding}`;
}
// A proof: that message, signed now with the agent's key.
function proof(purpose: string, binding?: string) {
const timestamp = Date.now();
const message = proofMessage(purpose, deviceId, kid, timestamp, binding);
const signature = sign(null, Buffer.from(message, "utf8"), privateKey).toString("base64url");
return { message, signature, timestamp };
} | Purpose | Message | Used by |
|---|---|---|
kya-register:v1 | kya-register:v1:<device_id>:<kid>:<timestamp_ms> | POST /v1/registrations |
kya-approve:v2 | kya-approve:v2:<device_id>:<kid>:<timestamp_ms>:<binding> | POST /v1/approvals (see step 5) |
kya-revoke:v1 | kya-revoke:v1:<device_id>:<kid>:<timestamp_ms> | POST /v1/agents/{device_id}/revoke |
timestamp is Unix milliseconds and must be within 5 minutes of KYA's clock, either way. The signature is Ed25519 over the UTF-8 bytes of the message, in base64 or base64url. Sign a fresh proof for each request.
3. Register the agent#
Registration is an approval. The call returns links.qr_url and links.app_link; the owner scans the QR code with their phone (or opens the app link on the phone that has the Very app) and completes a palm scan; you poll GET /v1/approvals/{approval_id} until it ends. Polling is also what completes the approval, so keep polling until the status is no longer pending.
const KYA = "https://api.very.org/kya"; // staging: https://api.stg.very.org/kya
async function post(path: string, body: unknown) {
const res = await fetch(`${KYA}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${res.status} ${json.error}: ${json.message}`);
return json;
}
// Poll an approval about every 2 seconds until it is no longer pending.
async function waitForApproval(approvalId: string) {
for (;;) {
const res = await fetch(`${KYA}/v1/approvals/${approvalId}`);
const approval = await res.json();
if (!res.ok) throw new Error(`${res.status} ${approval.error}: ${approval.message}`);
if (approval.status !== "pending") return approval;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
let registration = await post("/v1/registrations", {
device_id: deviceId,
public_key: publicKeySpki,
proof: proof("kya-register:v1"),
});
// Show links.qr_url as a QR code, or open links.app_link on the phone with the Very app.
console.log("Scan with the Very app:", registration.links.qr_url);
registration = await waitForApproval(registration.approval_id);
if (registration.status !== "completed") {
throw new Error(`registration ${registration.status}: ${registration.failure_reason ?? ""}`);
}
console.log("Registered to owner", registration.result.agent.owner_id); - Whoever completes the approval becomes the owner. Show the code only to the person who should own the agent, and tell them what they are approving: the Very app shows "KYA by VeryAI" but not yet the operation.
- A registration approval expires after 15 minutes. When it completes,
result.agentholdsdevice_id,kid,owner_idandregistered_at. - The call fails with
409agent_exists,agent_revoked(the device id belonged to a revoked agent) orkey_already_registered, and with400for a bad device id, key or proof. - A failed approval has a
failure_reason, for exampleaccess_denied(refused in the Very app) orowner_suspended(the person is not in good standing with Very).
4. Sign actions#
For each request that needs it, the agent signs an action token and sends it with the request, by convention in the Kya-Action header. The token binds the request through action_hash: base64url (no padding) of the SHA-256 of the RFC 8785 (JCS) form of a JSON action describing the request. KYA does not fix the action's shape: the agent and the relying party must build the same JSON from the request. The samples use the method, the path and the body.
// RFC 8785 (JCS) for plain JSON data: object keys sorted, no whitespace.
function jcs(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(jcs).join(",")}]`;
const keys = Object.keys(value).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${jcs((value as Record<string, unknown>)[k])}`).join(",")}}`;
}
// action_hash: base64url(SHA-256(JCS(action))), without padding.
function actionHashOf(action: unknown): string {
return createHash("sha256").update(jcs(action)).digest("base64url");
}
This jcs covers plain JSON data: strings, finite numbers, booleans, null, arrays and objects. For anything else, use a full RFC 8785 implementation.
// An action token: a compact JWS signed with the agent's key, valid for one request.
async function signAction(audience: string, actionHash: string, actionType?: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({ action_hash: actionHash, ...(actionType ? { action_type: actionType } : {}) })
.setProtectedHeader({ alg: "EdDSA", typ: "kya-action+jwt", kid })
.setIssuer(deviceId)
.setAudience(audience)
.setIssuedAt(now)
.setExpirationTime(now + 60) // at most 300 seconds after iat
.setJti(randomBytes(16).toString("base64url")) // 16 to 64 random bytes, never reused
.sign(privateKey);
}
const audience = "https://shop.example.com"; // the relying party's origin
const action = { method: "POST", path: "/api/orders", body: { sku: "A-100", quantity: 1 } };
const actionHash = actionHashOf(action);
await fetch(`${audience}${action.path}`, {
method: action.method,
headers: { "Content-Type": "application/json", "Kya-Action": await signAction(audience, actionHash, "order") },
body: JSON.stringify(action.body),
}); | Member | Rule |
|---|---|
Header alg | EdDSA |
Header typ | kya-action+jwt |
Header kid | The agent's kid. jwk, jku, x5u, x5c, x5t, x5t#S256 and crit are refused: KYA takes the key only from its registry |
iss | The agent's device_id |
aud | The relying party's origin, as a string (or an array holding exactly one string) |
iat, exp | Integer Unix seconds. exp at most 300 seconds after iat. iat may be up to 60 seconds ahead of KYA's clock; exp and an optional nbf are exact |
jti | 16 to 64 random bytes, base64url. Each one is accepted once |
action_hash | base64url of the 32-byte SHA-256 of the action |
action_type | Optional label, returned in the verdict |
The whole token is at most 4096 bytes. Sign a new token for every request, including retries.
5. Ask the owner to approve an action (step-up)#
When a relying party requires it, the agent asks its owner to approve one action with a palm scan. Only the agent can start an action approval, with a kya-approve:v2 proof whose binding covers exactly what the owner is asked to approve: the audience, the action hash and the description. When it completes, result.authorization is a kya-authorization+jwt for that action; the agent sends it with a fresh action token, by convention in the Kya-Authorization header.
// binding: base64url(SHA-256(u32be(len(audience)) || audience || digest
// || u32be(len(description)) || description)), without padding.
// Lengths count UTF-8 bytes; digest is the 32 raw bytes behind action_hash.
function approvalBinding(audience: string, actionHash: string, description = ""): string {
const u32be = (n: number) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n);
return b;
};
const aud = Buffer.from(audience, "utf8");
const digest = Buffer.from(actionHash, "base64url");
const desc = Buffer.from(description, "utf8");
return createHash("sha256")
.update(Buffer.concat([u32be(aud.length), aud, digest, u32be(desc.length), desc]))
.digest("base64url");
}
const description = "Order 1 x A-100";
let stepUp = await post("/v1/approvals", {
device_id: deviceId,
audience,
action_hash: actionHash,
action_description: description,
proof: proof("kya-approve:v2", approvalBinding(audience, actionHash, description)),
});
console.log("Owner, approve in the Very app:", stepUp.links.qr_url);
stepUp = await waitForApproval(stepUp.approval_id);
if (stepUp.status !== "completed") {
throw new Error(`approval ${stepUp.status}: ${stepUp.failure_reason ?? ""}`);
}
// Sign a fresh action token now, and send both.
await fetch(`${audience}${action.path}`, {
method: action.method,
headers: {
"Content-Type": "application/json",
"Kya-Action": await signAction(audience, actionHash, "order"),
"Kya-Authorization": stepUp.result.authorization,
},
body: JSON.stringify(action.body),
}); bindingis base64url (no padding) of SHA-256 overu32be(len(audience)) || audience || digest || u32be(len(description)) || description: lengths are 4-byte big-endian byte counts, strings are UTF-8,digestis the 32 raw bytes of the action hash, and an absent description is the empty string.- Only the owner's palm can complete it. It expires after 5 minutes, and the owner must be in good standing (otherwise
403 owner_suspended). - The authorization is valid for 5 minutes (
result.authorization_expires_at) and works once. It is bound to the agent, the owner, the audience and the action hash. action_description(at most 200 characters) is stored with the approval; the Very app does not display it yet.
Check your binding code with this vector. With device_id dev_test, kid kid_test and timestamp 1792500000000:
| Input | Value |
|---|---|
audience | solana:mainnet:11111111111111111111111111111111 |
action_hash | SHA-256 of the 3 bytes abc: ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0 |
action_description | Pay 1 USDC |
| Message to sign | kya-approve:v2:dev_test:kid_test:1792500000000:NK6bLs7zncbp6nYvWzhVf6yX83NFUFHE7BOYqG5tL8Y |
6. Revoke the agent#
The agent can revoke itself at once with a kya-revoke:v1 proof:
const revoked = await post(`/v1/agents/${deviceId}/revoke`, {
proof: proof("kya-revoke:v1"),
reason: "agent retired",
});
console.log(revoked.status, revoked.revoked_at); // "revoked", and when
Revocation is permanent: the key and the device_id can never be registered again. Repeating the call returns the original revocation.
Relying-party side#
7. Verify an action#
Forward the agent's action token to POST /v1/actions/verify with your own view of the request: your origin as expected_aud, and the action hash you compute from the request you received as expected_action_hash. KYA never takes either from the token. Use the same jcs and actionHashOf as the agent (step 4).
const KYA = "https://api.very.org/kya";
const MY_ORIGIN = "https://shop.example.com"; // the aud agents sign for
// Ask KYA for a verdict on the request's action token. Gate on verdict.valid.
async function verifyAction(headers: Headers, action: unknown, requireHuman = false) {
const actionToken = headers.get("Kya-Action");
if (!actionToken) return null; // no KYA agent behind this request
const res = await fetch(`${KYA}/v1/actions/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action_token: actionToken,
expected_aud: MY_ORIGIN,
expected_action_hash: actionHashOf(action), // from the request you received
human_authorization: headers.get("Kya-Authorization") ?? undefined,
require_human_authorization: requireHuman,
}),
});
if (res.status !== 200) throw new Error(`KYA did not answer: ${res.status}`); // fail closed
return res.json();
}
// In the handler for POST /api/orders:
const verdict = await verifyAction(request.headers, { method: "POST", path: "/api/orders", body });
if (!verdict?.valid) {
// Refuse the request. verdict.reason_codes says why.
} - Gate on
valid. It is true only when the signature verifies, the token is current,audandaction_hashmatch, the agent is active, its owner is in good standing, the human authorization (when sent or required) is valid and bound to this agent, owner, audience and action, and thejtiwas never accepted before. - Every refusal is a
200verdict withreason_codes, such assignature_invalid,aud_mismatch,action_hash_mismatch,jti_replayed,agent_revoked,owner_suspendedorhuman_authorization_required. See the full list. - Fail closed.
400is a malformed request.503means KYA or Very could not answer, and KYA does not vouch. - Single use. The
jti, and any human authorization presented, are spent only when everything else passed. A valid human authorization is spent even when you did not require one: it authorizes exactly one action. - Set
require_human_authorizationtotruefor actions that need the owner's approval each time. Without one, the verdict hashuman_authorization_requiredand the agent should step up. - A valid verdict also carries
owner_id(KYA's stable id for the owner, the same across their agents) and areceipt: akya-receipt+jwtvalid for 5 minutes that you can keep as evidence. It is never a credential.
8. Look up a Web Bot Auth key#
An agent's kid is the JWK thumbprint that Web Bot Auth uses as its keyid. When an agent signs its HTTP requests with Web Bot Auth (HTTP Message Signatures) using its KYA key, look the keyid up with GET /v1/keys/{kid} to get the public key and whether the agent is active and its owner in good standing:
import { createPublicKey } from "node:crypto";
// keyid: from the Signature-Input header of a Web Bot Auth request.
async function kyaAgentKey(keyid: string) {
const res = await fetch(`${KYA}/v1/keys/${encodeURIComponent(keyid)}`);
if (res.status === 404) return null; // not a KYA agent
if (!res.ok) throw new Error(`KYA lookup failed: ${res.status}`);
const agent = await res.json();
if (agent.status !== "active" || agent.owner_standing !== "good") return null;
// Verify the request's HTTP message signature with this key.
return createPublicKey({ key: Buffer.from(agent.public_key, "base64"), format: "der", type: "spki" });
}
The lookup returns the agent's public entry; it is not a decision. owner_standing is unknown when Very did not answer, so treat anything but good as not vouched for. Verify the HTTP message signature yourself; this check does not bind a request or prevent replay. To gate an action, use actions/verify.
9. Verify a plain signature#
POST /v1/signatures/verify checks a detached Ed25519 signature over a message of up to 16 KiB, 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 else: no audience, no action, no replay check. The message and its freshness are yours, so sign a nonce you chose.
// 1. The relying party gives the agent a fresh nonce. It must not start with "kya-".
const nonce = `login:${randomBytes(16).toString("base64url")}`;
// 2. The agent signs it with its key.
const signature = sign(null, Buffer.from(nonce, "utf8"), privateKey).toString("base64url");
// 3. The relying party asks KYA. Send exactly one of device_id or kid.
const res = await fetch(`${KYA}/v1/signatures/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kid, message: nonce, signature }),
});
const signatureVerdict = await res.json();
// signatureVerdict.valid: an active agent whose owner is in good standing signed this nonce.
// The nonce is yours to check: one you issued, still fresh, used once. Messages that start with kya- are KYA's own proofs and are refused with reserved_message.
10. Revoke as the owner#
The owner can revoke one agent, or every agent they hold. Anyone can start an owner revocation (the agent, a relying party, the owner on another device): the owner may have lost the agent. Only the owner's palm completes it; any other palm fails the approval with owner_mismatch. This uses post and waitForApproval from step 3.
// scope "agent" revokes this agent; "all" revokes every agent of its owner.
let revocation = await post(`/v1/agents/${deviceId}/owner-revocations`, { scope: "agent", reason: "lost laptop" });
console.log("Owner, approve in the Very app:", revocation.links.qr_url);
revocation = await waitForApproval(revocation.approval_id);
console.log(revocation.status, revocation.result?.revoked_device_ids);
The approval expires after 5 minutes. scope: "all" can be started from any of the owner's agents, revoked or not. When it completes, result.revoked_device_ids lists the agents it revoked.
Reverse CAPTCHA#
KYA issues challenges that software solves in seconds and a person at a keyboard cannot finish within the time limit. A correct answer earns a capability token (kya-capability+jwt, valid for one hour). It proves that a KYA challenge was solved by software in time, and nothing more: not that an AI solved it, and not whose agent it is. It names no agent and no owner.
| Type | What it takes | Time limit |
|---|---|---|
byte_transform | Apply 2 to 4 written instructions to 256 bytes (payload.data, base64) and return the lowercase hex SHA-256 of the result | 30 s |
constrained_gen | Write text on payload.topic with exactly lines lines, word_count words in total, and first characters whose ASCII codes sum to ascii_target | 20 s |
structured_extraction | Find the authoritative values of payload.fields in a document full of stale decoys, and return them joined with | in the order of fields | 30 s |
const challenge = await post("/v1/challenges", { type: "byte_transform" }); // omit type for a random one
const solution = await solveChallenge(challenge.challenge_type, challenge.payload); // your agent's code
const outcome = await post("/v1/challenges/verify", { token: challenge.token, solution });
if (outcome.success) {
console.log("capability token:", outcome.capability_token);
} else {
console.log("not accepted:", outcome.reason);
}
Each challenge allows one attempt. A refusal is a 200 with success: false and a reason: incorrect_solution, challenge_expired, challenge_replayed or challenge_invalid. A relying party verifies a capability token offline with KYA's JWKS:
import { createRemoteJWKSet, jwtVerify } from "jose";
const kyaKeys = createRemoteJWKSet(new URL("https://api.very.org/kya/.well-known/jwks.json"));
const { payload } = await jwtVerify(capabilityToken, kyaKeys, {
issuer: "https://api.very.org/kya",
typ: "kya-capability+jwt",
algorithms: ["EdDSA"],
});
// payload.challenge_type, payload.solve_time_ms, payload.solved_at.
// To accept each token once, keep payload.jti until payload.exp. Need help?#
See the API Reference for every endpoint, or contact support@very.org.