The Adjuro Attestation Protocol

How an attestation is structured, signed, and verified, so any third party can confirm who an AI agent was, what consent authorized the call, and on whose behalf, offline, in under 50 milliseconds.

VERSION V0
LAST UPDATED May 22, 2026

01Overview

An Adjuro attestation is a JOSE-standard JWS (JSON Web Signature) issued the moment an AI voice agent places a call on behalf of a brand, or an AI agent makes an API call on its behalf. It is a sworn attestation, adjurō, "I attest under oath", that binds five facts together under a single Ed25519 signature: who the agent is, what consent authorized the call, on whose behalf it was placed, under what scope and jurisdiction, and at what instant.

An attestation is evidence of the tenant's attestation; it is not, by itself, proof that the recipient consented. Its value is that the attestation is cryptographically non-repudiable and independently verifiable: a TCPA defense attorney, a compliance dashboard, or opposing counsel can verify it against Adjuro's published keys without trusting, or even contacting, Adjuro at verification time.

What a verifier learns

The signature proves the attestation payload was attested byte-for-byte by the holder of a specific Adjuro signing key at issuance time, and has not been altered since. Everything a relying party needs to make that determination is in the attestation and the public JWKS, no Adjuro account, no API call to us, no network dependency on our uptime.

02Attestation payload schema

Attestations use dual naming: each JOSE-standard claim (RFC 7519) is paired with a human-readable alias carrying the identical value. Both are populated on every attestation. Verifier code may use either set; non-developer compliance reviewers and FRE 901 evidence packets benefit from the human-readable form.

PurposeJOSE-standardHuman-readable aliasType / Format
Issuer URLississued_bystring (URL), same value in both
Attestation opaque IDjtiattestation_id (and receipt_id)string (adj_att_<base32>; artifacts issued before the rename carry adj_rcpt_<base32>), same value in all three
Issuance instantiatissued_atiat = unix seconds (int); issued_at = RFC 3339 (string). Same instant.
Expiry instantexpexpires_atexp = unix seconds (int); expires_at = RFC 3339 (string). Same instant.
Replay noncenoncereplay_tokenstring (16+ chars opaque), same value

Adjuro-specific claims

These are not JOSE-standard and are single-named:

FieldTypeDescription
tenant_iduuid stringThe Adjuro tenant that authorized this attestation
trust_root_idstringThe stable federation trust-root authority (adjuro-root). Rotation-independent, distinct from the rotating signing key in the header kid.
agent_idstringadj:<base32-blake3>, deterministic from tenant + agent name
brandstringDisplay name of the brand the call is made on behalf of
event_typeenumShipped: voice_call, api_request (see the API-request claims below). Reserved: chat_message, email_send, payment_auth, workflow_action
caller_numberE.164 stringCaller side, plaintext (no PII concern on the caller side)
callee_hashbase64url stringHMAC-SHA256 of callee E.164 under the per-tenant salt, recipient's plaintext number is never received or stored
campaign_idstringCustomer-supplied opaque reference (indexed for audit queries)
consent_idstringCustomer-supplied opaque CRM reference, Adjuro stores but never resolves
scopestring[]V0 enum: account_servicing, appointment_reminder, debt_collection, marketing, support, verification
jurisdictionstringISO 3166-2 (e.g., US-CA, GB, DE-BY)
source_urlstring | nullOptional customer-controlled URL pointing to the consent artifact

Trust claims

Four claims state how far Adjuro vouches for the brand. They are decided once, at mint time, from the tenant's environment and its domain verifications, and they are written by the issuer itself: the request that asks for an attestation cannot set or override any of them. A genuine signature and a verified brand are separate facts. valid means the signature is Adjuro's; only brand_verified: true means Adjuro vouches for the brand.

FieldTypeDescription
brand_verifiedbooleantrue only when the tenant is in the live environment and the signed brand exactly equals a domain the tenant has proven DNS control of and that has been approved. The only trust input: treat the brand as verified if and only if this is literally true. Anything else means the brand is self-asserted.
brand_domainstring | nullThe approved domain that backs brand_verified: true (equal to brand). null whenever brand_verified is false.
trust_tierenumverified | domain_verified | unverified. Display only; never use it for the trust decision. domain_verified means DNS control of the brand's domain is proven but approval is still pending.
environmentenumsandbox | live: the tenant's environment at the moment of signing. A sandbox attestation never carries brand_verified: true, whatever domains its tenant has verified.

brand_domain and environment were added in October 2026 under the additive rule in §06. Attestations signed before then carry brand_verified and trust_tier only, and the earliest, from before June 2026, carry none of the four (verifiers report their tier as legacy, not verified). The absence of a newer claim is never a downgrade: those attestations verify exactly as they always have.

API-request claims (event_type: api_request)

An attestation minted through POST /v1/api-attestations carries the shared claims above (tenant_id, trust_root_id, agent_id, brand, brand_verified, trust_tier, scope, jurisdiction, the JOSE pair) and the fields below. The voice-only fields (caller_number, callee_hash, campaign_id, consent_id, source_url) are absent. It is minted completed: there is no call to settle, so no webhook flips its status.

FieldTypeDescription
action_typestring (1–100)Customer-defined verb for the action, e.g. tool_call, completion, mcp_call
target_endpointstring (URL)The endpoint the agent called
request_hashSHA-256 hex (64)Digest of the request body. Adjuro receives the digest only; the body is never sent
response_hashSHA-256 hex (64) | nullDigest of the response body, optional
scopestring[]Free-form on api_request; the voice-call enum does not apply

The signature, protected header, verification algorithm, trust model, and transparency-log inclusion are identical across modalities. A verifier that handles voice_call handles api_request with no change.

03JWS protected header

Every attestation carries this protected header:

{ "alg": "EdDSA", "kid": "adjuro-root-2026w23", "typ": "JWT" }
  • alg: EdDSA, pure Ed25519 per RFC 8037. Verifiers MUST reject any attestation whose alg is not exactly EdDSA (algorithm-confusion defense).
  • kid, the signing-key id from Adjuro's signing_keys history at issuance time. Used to resolve the public key in the JWKS. Keys rotate every 14 days with a 48-hour overlap.
  • typ: JWT, for out-of-the-box compatibility with standard JOSE libraries on the verify path.

Load-bearing: pure Ed25519, not pre-hash (D15)

Adjuro signs with ED25519_SHA_512 (pure Ed25519, RFC 8032 §5.1), never ED25519_PH_SHA_512 (pre-hash). JOSE RFC 8037 defines alg: EdDSA as pure mode, and there is no registered JWS identifier for pre-hash Ed25519, so pre-hash would break every standard JOSE library (jose, python-jose, go-jose). The HSM signs the actual attestation bytes, not an application-computed hash, which also yields a cleaner FRE 901 evidentiary statement: "AWS KMS attested to this specific attestation payload byte-for-byte."

04Verification algorithm

Verification is fully offline against the published JWKS. The only network fetch, the JWKS itself, is cacheable for 5 minutes at the CDN edge and can be pinned for air-gapped review. Steps 7 and 8 are optional hardening.

# Verify an Adjuro attestation (compact JWS: header.payload.signature) function verifyReceipt(jws, options): header = base64urlDecodeJSON(segment(jws, 0)) payload = base64urlDecodeJSON(segment(jws, 1)) # 1. Reject anything that is not pure Ed25519 (alg-confusion defense) assert header.alg == "EdDSA" # 2. Resolve the signing key by kid against the published JWKS jwks = httpGet("https://api.adjuro.ai/.well-known/jwks.json") # 5-min CDN cache key = jwks.keys.find(k => k.kid == header.kid) assert key != null # unknown kid -> reject # 3. Verify the Ed25519 signature over the EXACT signed bytes. # Pure Ed25519 / RFC 8032, the verifier does NOT pre-hash. signedInput = segment(jws, 0) + "." + segment(jws, 1) assert ed25519Verify(key, signedInput, signature(jws)) # 4. Temporal validity (attestations are short-lived) now = unixSeconds() assert payload.iat <= now assert now < payload.exp # 5. Trust root must be one the relying party accepts. # acceptedTrustRoots holds STABLE authority ids (e.g. "adjuro-root"), # NOT rotating JWKS kids, trust_root_id is decoupled from header.kid. assert payload.trust_root_id in options.acceptedTrustRoots # 6. (optional) Confirm the attestation has not been revoked if options.checkRevocation: rev = httpGet("https://api.adjuro.ai/v1/revocations") # SDK polls /15min assert payload.jti not in rev.revoked_attestation_ids # 7. (optional) Confirm public transparency-log inclusion if options.checkTransparencyLog: proof = httpGet("https://log.adjuro.ai/v1/transparency-log/proof/" + payload.jti) assert verifyInclusionProof(proof, payload.jti) return VERIFIED(payload)

The open-source verifier SDK (adjuro on npm, Apache 2.0, TypeScript/Node) implements exactly this algorithm. Because alg: EdDSA is a JOSE standard, a hand-rolled verifier using any compliant JOSE library also works without an Adjuro dependency.

05Trust model

Adjuro is designed as a federated trust network, not a single issuer. The long-term shape is "Let's Encrypt for AI agents": multiple operators run their own signing roots, and Adjuro runs the verifier registry that lets any relying party resolve and trust them.

Trust roots

An attestation's trust_root_id names the stable root authority that attested it, a rotation-independent identifier, deliberately not week-stamped. It is distinct from the header kid, which names the specific signing key (rotated every 14 days) that produced the signature. A verifier allowlists root authorities, not individual keys, so an accepted-roots list never changes on key rotation. Three roots ship in V0:

  • adjuro-root, active. The production Adjuro root authority. Its signing keys rotate every 14 days and are named adjuro-root-2026w<NN> (ISO year + week) in the JWKS, surfaced in the header kid; the authority id itself stays constant. (Attestations issued before this rename carry the legacy value adjuro-root-2026w20 in trust_root_id; they remain individually signature-verifiable.)
  • vapi-root-reserved, reserved placeholder for the Vapi platform to run its own root.
  • retell-root-reserved, reserved placeholder for the Retell platform.

When a voice-agent platform operates its own signing root, every attestation it issues verifies through the same registry with no retrofit on the verifier side. A separate adjuro-degraded-2026w20 key signs degraded-mode entries (issued when signing is briefly unavailable and the call proceeds unsigned), kept distinct from the production root so degraded evidence is never confused with a clean attestation.

Why federation is the structural defense

Because Adjuro runs the verifier registry rather than being the only issuer, it stays neutral across trust boundaries, an incumbent bundling identity into its own platform cannot replicate cross-operator neutrality without becoming a neutral registry itself. Verification is winner-take-most once a transparency log plus federation reach critical mass.

06Schema stability commitment

Frozen at V0 ship. Every field name in §02, the JOSE-standard set (iss, jti, iat, exp, nonce), the human-readable alias set (issued_by, attestation_id, receipt_id, issued_at, expires_at, replay_token), and every Adjuro-specific field, is a public contract. Changing any field name after V0 ships breaks every deployed verifier in the wild.

Allowed without a breaking change

  • Adding new fields to the payload (verifiers ignore unknown fields per JOSE convention).
  • Adding new enum values to scope or event_type (existing values keep working).
  • Adding new top-level Adjuro-specific claims.
  • The receipt → attestation rename was done under exactly this rule: attestation_id was added. receipt_id is still emitted on every attestation and is not scheduled for removal — dropping it would be a breaking change under the rules below.

Requires a breaking-version bump + multi-year deprecation window

  • Renaming any existing field.
  • Removing any existing field.
  • Changing a field's type or format, the dual-naming convention exists specifically so neither representation of a timestamp ever needs to change.
  • Removing enum values from scope or event_type.

This commitment is enforced in CI by the integration test "attestation payload contains both JOSE-standard and human-readable claim names". If anyone ever drops a field, that test fails immediately.