Documentation
Implement UniKey Cloud
Add agent verification to any service that receives agent traffic. Forward the incoming Trust Packet to one endpoint and get a cryptographic verdict — before you execute the action.
Overview
Your service receives a request from an AI agent that carries a Trust Packet — a signed JSON object asserting identity, authority, and scope. You POST that packet to UniKey Cloud, which verifies the signature against the signer's DNS-published key, checks expiry, audience, scope, and any delegation chain, and returns a verdict. There are two endpoints:
POST /api/v1/verify-packet
Verify a self-contained Trust Packet (JSON body).
POST /api/v1/verify
Verify a signed HTTP request via X-UniKey-* headers.
Base URL: https://www.unikeycloud.com/api/v1
You authenticate to the API with UniKey itself — you sign your request with your domain's key (no API keys). Same primitive you're verifying; see Authentication.
Quickstart
-
1
Add your domain
In the dashboard under Domains, add the domain your service calls from (e.g.
acme.com). You'll get an Ed25519 key and the exact DNS record to publish. -
2
Publish the DNS record & verify
Add the shown
TXTrecord atunikey._domainkey.<domain>, then click Verify. This is how the API knows the request is really you. -
3
Sign & send a verification
Sign your request with your domain key (see Authentication) and POST a Trust Packet to
/api/v1/verify-packet. -
4
Act on the verdict
Reject the action unless
validistrue.
Authentication
There are no API keys. You authenticate by signing the request itself with your verified domain's Ed25519 key — the same DNS-published-key primitive you're using UniKey to verify. We resolve your key from DNS and map the signer domain to your account.
Build a canonical string and sign it, then send five headers:
POST https://www.unikeycloud.com/api/v1/verify-packet <base64( sha256(request body) )> <unix timestamp, seconds> <agent email>
X-UniKey-Signer: acme.com # your verified domain X-UniKey-Timestamp: 1769450662 # must be within 5 minutes X-UniKey-Body-Hash: base64(sha256(body)) X-UniKey-Signature: base64(ed25519_sign(canonical)) X-Agent-Email: agent@acme.com
The signed URL must exactly match the endpoint URL (scheme + host + path), and the body must be byte-identical to what you hash and send. Requests older than 5 minutes are rejected. Worked, runnable examples are below.
Publish your key
Publish your Ed25519 public key as a DNS TXT record so we (and any verifier) can resolve it — the same mechanism
DKIM uses for email. The dashboard generates the key and shows you the exact record; this is what makes your
signed requests authenticate. Generate a keypair with openssl genpkey -algorithm ed25519 if you bring your own.
unikey._domainkey.yourdomain.com. IN TXT "v=DKIM1; k=ed25519; p=MCowBQYDK2VwAyEA…"
Verify a Trust Packet
Sign the request (see Authentication) and POST the packet you received to /api/v1/verify-packet. These examples are verified end-to-end:
# needs your Ed25519 key as ed25519.pem and the packet in trust-packet.json URL="https://www.unikeycloud.com/api/v1/verify-packet" TS=$(date +%s) HASH=$(openssl dgst -sha256 -binary trust-packet.json | openssl base64 -A) SIG=$(printf '%s\n%s\n%s\n%s\n%s' POST "$URL" "$HASH" "$TS" agent@acme.com \ | openssl pkeyutl -sign -inkey ed25519.pem -rawin | openssl base64 -A) curl "$URL" \ -H "X-UniKey-Signer: acme.com" \ -H "X-UniKey-Timestamp: $TS" \ -H "X-UniKey-Body-Hash: $HASH" \ -H "X-UniKey-Signature: $SIG" \ -H "X-Agent-Email: agent@acme.com" \ -H "Content-Type: application/json" \ --data-binary @trust-packet.json
require "net/http"; require "json"; require "digest"; require "base64"; require "ed25519" url = "https://www.unikeycloud.com/api/v1/verify-packet" body = JSON.generate(packet) # the Trust Packet you received ts = Time.now.to_i.to_s hash = Base64.strict_encode64(Digest::SHA256.digest(body)) canon = ["POST", url, hash, ts, "agent@acme.com"].join("\n") key = Ed25519::SigningKey.new(seed) # your 32-byte Ed25519 seed sig = Base64.strict_encode64(key.sign(canon)) res = Net::HTTP.post(URI(url), body, "X-UniKey-Signer" => "acme.com", "X-UniKey-Timestamp" => ts, "X-UniKey-Body-Hash" => hash, "X-UniKey-Signature" => sig, "X-Agent-Email" => "agent@acme.com", "Content-Type" => "application/json") head :forbidden unless JSON.parse(res.body)["valid"]
import { createHash } from "node:crypto"; import * as ed from "@noble/ed25519"; const url = "https://www.unikeycloud.com/api/v1/verify-packet"; const body = JSON.stringify(packet); const ts = Math.floor(Date.now() / 1000).toString(); const hash = createHash("sha256").update(body).digest("base64"); const canon = ["POST", url, hash, ts, "agent@acme.com"].join("\n"); const sig = Buffer.from(await ed.signAsync(Buffer.from(canon), seed)).toString("base64"); // seed: 32-byte key const res = await fetch(url, { method: "POST", headers: { "X-UniKey-Signer": "acme.com", "X-UniKey-Timestamp": ts, "X-UniKey-Body-Hash": hash, "X-UniKey-Signature": sig, "X-Agent-Email": "agent@acme.com", "Content-Type": "application/json", }, body, }); if (!(await res.json()).valid) reject();
import base64, hashlib, time, json, requests from nacl.signing import SigningKey url = "https://www.unikeycloud.com/api/v1/verify-packet" body = json.dumps(packet).encode() ts = str(int(time.time())) hash = base64.b64encode(hashlib.sha256(body).digest()).decode() canon = "\n".join(["POST", url, hash, ts, "agent@acme.com"]).encode() sig = base64.b64encode(SigningKey(seed).sign(canon).signature).decode() # seed: 32 bytes res = requests.post(url, data=body, headers={ "X-UniKey-Signer": "acme.com", "X-UniKey-Timestamp": ts, "X-UniKey-Body-Hash": hash, "X-UniKey-Signature": sig, "X-Agent-Email": "agent@acme.com", "Content-Type": "application/json", }) if not res.json()["valid"]: abort(403)
The Trust Packet body
The packet you forward looks like this (you receive it from the agent — you don't build it):
{
"header": { "tp_version": "1.0", "packet_id": "pkt_9a4f…", "issued_at": "2026-05-26T18:04:00Z", "expires_at": "2026-05-26T18:09:00Z", "nonce": "b7e2…" },
"claims": { "subject": "claude@acme.ai", "issuer": "acme.ai", "audience": "store.example", "scope": ["charge:60"] },
"payload": { "action": "purchase_item", "params": { "item": "Air Max", "amount": 55 } },
"signatures": [{ "algorithm": "ed25519", "signer": "acme.ai", "key_selector": "unikey", "signature": "MEUCIQ…" }]
}
Successful response
{
"valid": true,
"packet_id": "pkt_9a4f…",
"subject": "claude@acme.ai",
"issuer": "acme.ai",
"audience": "store.example",
"action": "purchase_item",
"scope": ["charge:60"],
"signer": "acme.ai",
"dns_hardened": false,
"environment": "live",
"verified_at": "2026-05-26T18:04:22Z"
}
Verify a signed request
When an agent signs an HTTP request directly (DKIM-over-HTTPS) instead of sending a packet, relay the agent's
signature headers to /api/v1/verify
(your own call is still signed for auth, as above):
X-UniKey-Signature: <base64 Ed25519 signature> X-UniKey-Signer: acme.ai X-UniKey-Timestamp: 1769450662 X-UniKey-Body-Hash: <base64 SHA-256 of body> X-Agent-Email: claude@acme.ai
Verify by email
A Trust Packet is verified the same way no matter how it reaches us — the packet's signature is the security, not the channel. So you can skip the HTTP call entirely: email a packet as JSON to your account's unique address and we verify it, record it to your ledger, and POST the result to your webhook. Find your address under Dashboard → Email.
To: verify-<your-token>@inbound.unikeycloud.com
Subject: (anything)
{ "header": { ... }, "claims": { ... }, "payload": { ... }, "signatures": [ ... ] }
We POST the same JSON the API returns to your configured webhook (or to a
callback_url named in the
packet). Each delivery is signed so you can confirm it came from us — verify the
X-UniKey-Signature header
before trusting the body:
POST https://your-app.com/unikey/webhook
X-UniKey-Event: verification
X-UniKey-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed by your webhook secret>
Content-Type: application/json
{ "valid": true, "packet_id": "...", "signer": "acme.ai", "environment": "live", ... }
Responses & errors
Every response includes a boolean valid.
Failures return a stable error code and a human-readable message.
| HTTP | error | Meaning |
|---|---|---|
| 200 | — | Verified. Check the valid field. |
| 401 | invalid_signature | Request or packet signature did not verify against the DNS key. |
| 401 | expired_request | Signed request is older than the 5-minute window. |
| 400 | missing_headers | A required X-UniKey-* header is absent. |
| 400 | invalid_packet | Malformed or missing required packet fields. |
| 403 | unknown_signer | Valid signature, but the signer domain isn't a verified account domain. |
| 403 | untrusted_signer | Signer is not allowed. |
| 422 | dns_lookup_failed | No UniKey key found for the signer domain. |
| 422 | dns_inconsistency | Resolvers disagreed — failed closed (hardening). |
Libraries
Prefer to verify in-process without the hosted API? The open-source verifier libraries implement the full protocol. UniKey Cloud runs the hardened infrastructure around them — see self-hosted.
Ruby
gem "unikey"
Node
npm i @unikey/verify
Python
pip install unikey-tp
Ready to verify your first agent?
UniKey Cloud is in an invite-only pilot today. Request access and we'll get you a test key.
Request access