Verification API
AIN Verify answers one question for businesses that receive requests from AI agents: is this agent allowed to do this, on behalf of whom? The agent's organization registers it, issues mandates in the dashboard, and gives the agent its private key. Your system calls /v1/verify before acting.
principal.identity_status field says so in every response.Authentication
Create an API key under API keys in the dashboard and send it as a bearer token. Keys are shown once and stored hashed.
Authorization: Bearer ainv_live_XXXXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
POST /v1/verify
Evaluates an action against the agent's status, its signing key and every mandate its organization has issued. Returns 200 with decision: "allow" | "deny" — a denial is a normal result, not an HTTP error.
| Field | Type | Description |
|---|---|---|
ain | string | Required. The agent's AIN, e.g. AIN-7KQ2-M9XD-4TRA. |
action.type | string | Required. purchase, subscription, payment, contract_signature, data_access, api_call or booking. |
action.amount | string | Decimal string, max 2 places (0 for JPY). Omit for non-monetary actions. |
action.currency | string | ISO 4217. Default USD. |
action.merchant | string | Your business name as it appears on the agent's mandate (case-insensitive). |
action.category | string | Optional category, matched against mandate categories. |
agent_proof | object | The agent's signature over this action. Required by most mandates — see Agent proofs. |
Call once, at the moment you commit. Every live allow for a positive amount immediately counts toward the mandate's cumulative limit, so a separate pre-check followed by a second call would count the amount twice. Sending "record": false returns 400 recording_required; dry runs are only available in the dashboard test console, where results are labelled environment: "test". An allow is not a settlement receipt — AIN has no reservation, cancel or refund lifecycle yet.
curl https://ainverify.com/v1/verify \
-H "Authorization: Bearer $AIN_API_KEY" -H "Content-Type: application/json" \
-d '{"ain":"AIN-7KQ2-M9XD-4TRA",
"action":{"type":"purchase","amount":"125.00","currency":"USD","merchant":"Acme Supply"},
"agent_proof":{"timestamp":"2026-10-30T14:02:11.000Z","nonce":"q8Zr2mVn0cXyP4tLw1","signature":"…"}}'
Response
{
"object": "verification",
"id": "ver_9fJ2kLm0QpX8aN1c",
"environment": "live",
"decision": "allow",
"reason_code": "AUTHORIZED",
"reason": "The action is within an active mandate issued by the agent’s organization.",
"evaluated_at": "2026-10-30T14:02:11.412Z",
"ain": "AIN-7KQ2-M9XD-4TRA",
"agent": { "name": "ATLAS Procurement", "status": "active", "key_fingerprint": "SHA256:…" },
"principal": { "organization_id": "org_…", "name": "Example Corporation", "identity_status": "self_attested" },
"agent_proof": { "status": "verified" },
"mandate": { "id": "mnd_…", "title": "Office supplies", "valid_until": "…", "currency": "USD",
"per_action_limit": "250.00", "remaining_total": "875.00" },
"action": { "type": "purchase", "amount": "125.00", "currency": "USD", "merchant": "Acme Supply" },
"usage_recorded": true,
"disclaimer": "…",
"signature": { "alg": "EdDSA", "kid": "ainv-…", "value": "…", "jwks_url": "https://ainverify.com/.well-known/ain-keys.json" }
}
agent_proof.status is one of verified, not_provided, invalid, stale, replayed.
Agent proofs
A proof shows the request came from the holder of the agent's private key and binds the signature to this exact action, so an intercepted proof can't be reused for a different amount or counterparty. The agent signs this UTF-8 string with Ed25519:
AIN-VERIFY-v1\n{ain}\n{timestamp}\n{nonce}\n{sha256_hex(canonical_json(action))}
timestamp: ISO-8601, within ±5 minutes of AIN's clock.nonce: 16–128 characters of[A-Za-z0-9_-], never reused.canonical_json: theactionobject exactly as sent in the request, serialized with keys sorted and no whitespace.- The agent passes
{timestamp, nonce, signature}to the counterparty alongside its request; the counterparty forwards it unchanged asagent_proof.
Node.js (agent side)
import crypto from 'node:crypto';
const canonical = (v) => v === null || typeof v !== 'object' ? JSON.stringify(v)
: Array.isArray(v) ? `[${v.map(canonical).join(',')}]`
: `{${Object.keys(v).sort().filter(k => v[k] !== undefined).map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',')}}`;
export function signAction(ain, action, privateKeyPem) {
const timestamp = new Date().toISOString();
const nonce = crypto.randomBytes(18).toString('base64url');
const digest = crypto.createHash('sha256').update(canonical(action)).digest('hex');
const msg = `AIN-VERIFY-v1\n${ain}\n${timestamp}\n${nonce}\n${digest}`;
const signature = crypto.sign(null, Buffer.from(msg), privateKeyPem).toString('base64url');
return { timestamp, nonce, signature };
}
Python (agent side)
import json, hashlib, secrets, datetime
from cryptography.hazmat.primitives.serialization import load_pem_private_key
def sign_action(ain, action, private_key_pem: bytes):
ts = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
nonce = secrets.token_urlsafe(18)
digest = hashlib.sha256(json.dumps(action, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
msg = f"AIN-VERIFY-v1\n{ain}\n{ts}\n{nonce}\n{digest}".encode()
sig = load_pem_private_key(private_key_pem, None).sign(msg)
import base64; return {"timestamp": ts, "nonce": nonce, "signature": base64.urlsafe_b64encode(sig).rstrip(b"=").decode()}
Reason codes
| Code | Meaning |
|---|---|
AUTHORIZED | Allowed by an active mandate. |
AGENT_NOT_FOUND | No agent has this AIN. |
AGENT_SUSPENDED / AGENT_REVOKED | The agent's identity is not active. |
ORGANIZATION_SUSPENDED | The agent's organization is suspended. |
AGENT_PROOF_INVALID / _STALE / _REPLAYED | A proof was supplied but failed. Never retry with the same proof. |
AGENT_PROOF_REQUIRED | The matching mandate requires a valid proof and none was supplied. |
NO_MANDATE | The agent holds no mandates. |
MANDATE_REVOKED / MANDATE_EXPIRED / MANDATE_NOT_YET_VALID | The closest mandate is not in force. |
ACTION_NOT_PERMITTED / CURRENCY_NOT_PERMITTED / CATEGORY_NOT_PERMITTED | Outside the mandate's scope. |
MERCHANT_NOT_AUTHORIZED | Your business is not on the mandate's counterparty list. |
LIMIT_EXCEEDED / CUMULATIVE_LIMIT_EXCEEDED | Over the per-action or total limit. |
When an agent holds several mandates, AIN allows if any one permits the action; otherwise it reports the reason from the mandate that came closest.
GET /v1/agents/:ain
Public, unauthenticated, rate-limited. Returns identity, status, principal and public key — never mandate contents. Also available as a web page at /a/{ain}.
GET /v1/verifications/:id
Retrieves a past decision made with your organization's API keys, e.g. for dispute evidence.
Decision signatures
Every verification and lookup response is signed by AIN with Ed25519. To check one: remove the signature field, serialize the rest as canonical JSON (sorted keys, no whitespace), and verify signature.value (base64url) with the key whose kid matches in /.well-known/ain-keys.json (JWK, crv: Ed25519).
AIN format
AIN-XXXX-XXXX-XXXX: 11 random Crockford base-32 characters plus a Luhn mod-32 check character. Identifiers are random, not sequential, and permanent — a revoked AIN is never reissued. Input is case-insensitive; O→0 and I/L→1 are normalized.
Errors & limits
{ "error": { "code": "invalid_api_key", "message": "API key is invalid or revoked" } }
400 validation (including recording_required) · 401 missing/invalid key · 403 organization suspended or registration closed · 404 not found · 429 rate limited (300 req/min per key; 120/min per IP for lookups). CORS is enabled for /v1/*.