Skip to Content
API referenceSigned governance receipts
Open in Claude Open in ChatGPT

Signed governance receipts

When the broker decides a call, it writes the audit row and a signed receipt in the same database write. The receipt is a compact JWS (ES256, typ: agentvalet-receipt+jwt) whose jti is the audit row’s id. Anyone holding the receipt can verify what AgentValet decided, when, for which agent, under which policy version, using only the public key set below. No database access, no AgentValet account.

Receipts are issued for every terminating outcome on POST /v1/actions: allowed, held for approval, denied by grant, policy or guardrail, blocked by response screening, refused for secrets or size, errored on redaction. They are also issued for every human decision on a held call (approval.approved and approval.denied, from the dashboard, the one-click or delegate email link, or a passkey), for every approval replay (executed, dropped or denied at replay, failed) and for every governed git operation. Two refusals carry none: an endpoint rejected by the SSRF guard (400 Endpoint not allowed, which writes no audit row at all) and an upstream call that throws (502 Upstream request failed with the generic detail). Denials get receipts too: proof that a call was refused is usually the more valuable evidence.

Finding the audit id

The call itself tells you:

  • On a 2xx, _meta.audit_id in the response envelope.
  • On a receipted refusal (e.g. 400 secrets, 403, 409, 412, the PII 502) or a hold (202), audit_id in the body next to correlation_id.
  • In an MCP host, the receipt card’s audit_id field.

It is a uuid, never the receipt itself, so the model’s context is not spent on 800 bytes of base64 per call.

Fetching the receipt

GET /v1/audit/ff505bfd-6a3f-4b1d-85a0-35b89d431bff/receipt Authorization: Bearer <agent JWT or owner session>
{ "audit_id": "ff505bfd-6a3f-4b1d-85a0-35b89d431bff", "kid": "av-receipt-2026-09b", "receipt": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50dmFsZXQtcmVjZWlwdCtqd3QiLCJraWQiOiJhdi1yZWNlaXB0LTIwMjYtMDliIn0...", "jwks_url": "https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json", "did": "did:web:api.agentvalet.ai", "verify_hint": "Compact JWS. Verify with alg ES256, typ agentvalet-receipt+jwt, iss https://api.agentvalet.ai, and a key from jwks_url ..." }

An agent bearer may fetch receipts for its own rows only; an owner session may fetch any row in its organisation. Accept: application/jose returns the bare JWS. 404 for a row that has no receipt:

  • rows written before 16 September 2026, and approval decision rows (approval.approved / approval.denied) written before decisions started being signed (October 2026);
  • rows written by the database rather than the proxy: an approval that expired undecided (approval.expired), an approved call that was never replayed (approval.execution_not_started), a held call cancelled because its grant was narrowed (approval.cancelled_scope_revoked) or because its agent was suspended, revoked or deleted (approval.cancelled_agent_suspended, _revoked, _deleted, from 13 October 2026), grant changes (grant.added, grant.updated, grant.renewed, grant.expired, grant.removed), a child suspended, revoked or deleted by its parent’s cascade (agent.suspended_by_parent, agent.revoked_by_parent, agent.deleted_by_parent) and a child past its expiry (agent.child_expired). These are state transitions, not decisions, and carry no receipt; they do appear in the audit export;
  • lifecycle rows the proxy or dashboard writes outside a governed call: a child issued (agent.child_issued), and an agent suspended, reactivated or deleted from the dashboard (agent_suspended, agent_reactivated, agent_deleted);
  • broker-ingested rows, Observe Mode relays, connect events and circuit-breaker transitions;
  • rows with no agent (for example passkey.used) answer 404 not_found on this endpoint even when signed: fetch those from the export.

Receipts also appear as the last column of the audit export (Team and above).

What the receipt says

{ "iss": "https://api.agentvalet.ai", "sub": "agt_9f3a1c2b7e401928d7ee", "jti": "ff505bfd-6a3f-4b1d-85a0-35b89d431bff", "iat": 1789553665, "av": { "v": 1, "trace_id": "287bf627-cbd1-44d2-97fa-80402981b137", "org_id": "3f2b8c1e-…", "owner_id": "…", "agent_id": "agt_9f3a1c2b7e401928d7ee", "parent_agent_id": null, "issuance_audit_id": null, "permission_id": "132a40d3-cfde-43b6-94c2-3d833d1f67d4", "session_id": "…", "session_id_verified": false, "platform": "github", "connection_id": null, "credential_source": null, "caller_hash": null, "caller_verified": null, "scope": "github:repo.read", "method": "GET", "endpoint": "/user", "result": "denied", "decision": { "reason": "endpoint_scope_mismatch", "policy_id": "5604561a-…", "policy_version": 6184, "guardrail_id": null, "matched": [{ "kind": "policy_allow_matched", "policyId": "5604561a-…", "rule": { "targetType": "platform", "targetValue": "github" } }], "scope_unmapped": false, "screen": null, "containment": null, "constraint": null, "reach": null }, "approval": null, "agent_reason_present": null, "agent_reason_sha256": null, "pii": { "detected": false, "redacted": false, "categories": [] }, "upstream_status": null, "row_hash": "sha256:119da7d8…" } }
FieldMeaning
trace_idThe correlation_id the agent was handed, on every plan
parent_agent_idSet for a child agent. On approval replay receipts (executed, dropped, denied at replay, failed) from proxy 1.6.10 (13 October 2026); before that a child’s replay receipts carried null and only its hold receipt named the parent
issuance_audit_idFor a child agent, the id of the agent.child_issued audit row that created it: the delegation record (parent, child, granted scopes, expiry). null for a top-level agent. From 13 October 2026; children issued earlier were backfilled from their issuance row where it still exists (not where it was purged or never written), so their new receipts carry it too
permission_idThe grant (permission row) the call was evaluated against, the same id the grant.* audit rows carry. On a replay receipt, the grant the replay ran under. null when no grant was selected (for example no_permission_record), on a replay dropped before the grant is read (the agent expired, was not active or failed its parent check) and on receipts issued before 13 October 2026
session_id_verified: falseThe session id is asserted by the caller and never verified; it is attribution, not identity
endpointPath only, never the query string
decision.matchedThe policy or guardrail rule events that decided the call, from the kernel’s trace
approvalOn a decision and on a replay: see Approval chain
payloadOn every receipt of a held call (hold, decision, replay): a salted commitment to the exact payload. See Payload commitment. Absent on other receipts and on held calls from before 21 October 2026
connection_idThe connection the call ran on (an opaque id, never its label). Set on governed rows from October 2026; null on MCP platforms, which have no connection
decision.basis"hold_time" on replay receipts: policy is not re-run when an approved call is replayed, so policy_id, policy_version and guardrail_id are the ones that held the call. Absent on live decisions
agent_reason_present / agent_reason_sha256Whether the agent sent a reason, and a hash of it, so an auditor can match it to the row without the text travelling. Recorded on every call the agent makes, allowed, denied or held, from October 2026 (before that, only on held calls). agent_reason_present: false means the agent sent none; null means the row predates the field. The decision and replay receipts of a held call carry null and point at the hold receipt, which has it
row_hashSHA-256 over the stored row’s identity fields and iat; an auditor with database access can additionally check the row was not edited after signing

Approval chain

A held call produces three receipts that point at each other: the hold (result: "pending_approval"), the human decision, and the replay.

"approval": { "id": "40e3ab6f-8b54-4963-858f-1b68568bfebd", "decision": "approved", "decided_at": 1791627000, "approver_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "approved_at": 1791627000, "decided_via": "dashboard", "pending_receipt_jti": "7d1e2f3a-4b5c-4d6e-8f9a-0b1c2d3e4f5a", "decision_receipt_jti": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" }
FieldMeaning
decision"approved" or "denied"
decided_atWhen the human decided (seconds), for approvals and denials alike
approver_idWho decided, as an id, never an email: the approving admin’s owner id, or delegate:<id> for an approval delegate
approved_atKept for verifiers written before decided_at: equal to decided_at on an approval, null on a denial
decided_viadashboard, owner_push, delegate_email, passkey, or magic_link for older links
pending_receipt_jtiThe receipt issued when the call was held
decision_receipt_jtiReplay receipts only: the decision receipt, so the chain reads held, decided, executed

The decision receipt itself (scope = approval.approved or approval.denied) has decision set and no decision_receipt_jti. decision, decided_at and decision_receipt_jti are absent on receipts issued before October 2026; v stays 1, and every field that existed before is unchanged, so existing verifiers keep working.

Payload commitment

The approval chain proves who decided and when. av.payload proves what they decided on. Every receipt of a held call (the hold, the approve or deny decision, and the replay rows) carries the same block:

"payload": { "scheme": "av-payload-v1", "approval_id": "40e3ab6f-8b54-4963-858f-1b68568bfebd", "commitment": "sha256:5d2c0f0e…" }

The commitment is computed once, when the call is held, over exactly what the replay will send:

canonical = JSON of {data, endpoint, headers, method}, object keys sorted at every depth, no whitespace commitment = "sha256:" + hex( sha256( salt_bytes || utf8(canonical) ) )
  • method and endpoint as sent, query string included (the receipt’s own endpoint field is path only).
  • data is the request body as stored and replayed.
  • headers are the forwarded headers. Secret-bearing headers are never stored (they are replaced by [redacted] and dropped at replay), so they are not in the commitment. null when there are none.
  • salt is 32 random bytes per held call, kept with the approval and never in a receipt.

Why salted. Payloads are often guessable: an amount, a payee, an email address. A bare hash in a receipt that leaves the building could be brute-forced back to the payload. With a secret salt the commitment reveals nothing until someone hands over the salt and the payload together.

Replay enforcement. Before an approved call runs, the proxy recomputes the commitment from the stored approval and compares it with the one the hold recorded on its own signed row. If they differ, or the stored commitment was removed or replaced after the hold, nothing is sent: the replay is recorded as approval.execution_denied with reason: "payload_commitment_mismatch", a signed receipt, and the values involved (payload_commitment, payload_commitment_hold, payload_commitment_recomputed, all salted hashes) in the row’s metadata. If the approval was decided on a committed request but its stored commitment cannot be read back at replay time, the call is refused too, with reason: "payload_commitment_unavailable". The database also refuses any change to a committed request’s body, envelope, salt or commitment.

Replay rows record payload_commitment_check: match, not_committed (a call held before 21 October 2026), mismatch, or unavailable (the stored commitment could not be read; refused when the decision was made on a committed request).

Where the call lands. The commitment covers what is sent. Since proxy 1.10.1 the replay also recomputes the held call’s identity hash, which additionally covers the connection it runs on, a pinned account and the person it runs for, and compares it with the hash the hold recorded. Re-pointing an approved call at another connection, account or person is refused the same way, with reason: "request_hash_mismatch" (the stored, held and recomputed hashes are in the row’s metadata). Replay rows record request_hash_check: match, not_hashed (a call held before 10 October 2026), mismatch or unavailable. This hash is checked server-side and is not part of av.payload.

Getting the opening. The salt and the exact inputs (the “opening”) are returned to callers already entitled to the payload:

  • GET /v1/audit/:id/receipt adds payload_opening to its JSON response for any receipt that carries av.payload (null otherwise).
  • The audit export adds payload_opening to each committed row with format=json&include=payload_openings. It is opt-in, because an opening contains the request body, which the export otherwise never does.
"payload_opening": { "scheme": "av-payload-v1", "approval_id": "40e3ab6f-8b54-4963-858f-1b68568bfebd", "commitment": "sha256:5d2c0f0e…", "salt": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "method": "POST", "endpoint": "/v1/payouts", "data": { "amount": 5000, "payee": "Acme" }, "headers": { "Stripe-Account": "acct_1" } }

Recomputing it (TypeScript, Node):

import { createHash } from "node:crypto"; const sortKeys = (v: unknown): unknown => Array.isArray(v) ? v.map((x) => (x === undefined ? null : sortKeys(x))) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().filter((k) => (v as any)[k] !== undefined).map((k) => [k, sortKeys((v as any)[k])])) : v; export function recomputeCommitment(o: { salt: string; method: string | null; endpoint: string | null; data: unknown; headers: Record<string, string> | null }) { const canonical = JSON.stringify(sortKeys({ data: o.data ?? null, endpoint: o.endpoint, headers: o.headers, method: o.method })); return "sha256:" + createHash("sha256").update(Buffer.from(o.salt, "hex")).update(canonical, "utf8").digest("hex"); } // verified receipt + opening: recomputeCommitment(opening) === receipt.av.payload.commitment

Python:

import hashlib, json def recompute_commitment(o: dict) -> str: canonical = json.dumps({"data": o["data"], "endpoint": o["endpoint"], "headers": o["headers"], "method": o["method"]}, sort_keys=True, separators=(",", ":"), ensure_ascii=False) return "sha256:" + hashlib.sha256(bytes.fromhex(o["salt"]) + canonical.encode("utf-8")).hexdigest()

The reference canonical form is JavaScript’s JSON.stringify over sorted keys. The Python version matches it for strings, booleans, nulls, integers and most decimals, but not for:

  • decimals (numbers written with a . or an exponent) whose size is below 0.0001 or at least 10^16 (Python prints 5e-05 where JavaScript prints 0.00005, and 1e+16 where JavaScript prints 10000000000000000);
  • object keys outside the Basic Multilingual Plane (the two languages sort them differently).

For payloads like these, recompute in JavaScript.

With a verified receipt and its opening, an auditor can show: this payload (the opening recomputes av.payload.commitment), this approver and decision (the decision receipt carries the same commitment), this execution (the replay receipt carries it too, and a changed payload would have been refused).

Older rows. Calls held before 21 October 2026 have no commitment: their receipts carry no av.payload, their opening is null, and their replays record payload_commitment_check: "not_committed". Treat that as “not committed”, not as a failed check.

Join contract

Every id below is stable for the life of its audit row, and the same value wherever it appears. This is how one action is followed from authority to result without inference.

FromFieldJoins to
Any receiptjtiThe audit row’s id: audit_id in the CSV export, id in the JSON export
Any receipt or rowtrace_id (receipt), correlation_id (CSV), request_id (JSON)Every row of the same request: the hold, the human decision and the replay
Decision and replay receiptsapproval.idThe approval request (approval_id in row metadata)
Decision and replay receiptsapproval.pending_receipt_jtiThe hold row’s id
Replay receiptsapproval.decision_receipt_jtiThe decision row’s id
Hold, decision and replay receiptspayload.approval_id + payload.commitmentThe approval request, and the payload it held (recompute from payload_opening)
A child’s receiptsagent_idThe child; parent_agent_id names its parent
A child’s receiptsissuance_audit_idThe agent.child_issued row. Its metadata.child_agent_id and metadata.parent_agent_id name the pair, and its metadata.grants the scopes the child was given
Receiptspermission_idThe grant. Every change to it is a grant.* row whose metadata.permission_id is the same id
Receiptsconnection_idThe connection the call ran on
Receiptspolicy_id + policy_versionThe policy version that decided (on replay: the one that held the call)
Lifecycle rowsagent_id, metadata.approval_id, metadata.parent_agent_idThe agent, the cancelled request and, for a cascade, the parent

Rows without a receipt are joined by their audit_id and these metadata fields; they are not signed (see Finding the audit id for which rows those are). An external authority record (a commission or delegation id from your own system) has no field yet: an agent can put one in its reason, which the hold receipt binds by hash (agent_reason_sha256) but which AgentValet does not check.

Never in a receipt: request or response bodies (a held call’s payload appears only as a salted commitment), IP address, user agent, geolocation, the free-text agent reason, connection labels (often an email), recipient addresses, the full policy trace, or any credential. A receipt is designed to leave the building.

Verifying offline

Keys are published in two forms: an RFC 7517 JWK Set at https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json (the active key, plus any retired keys the deployment keeps in its configured key set, each with a kid), and as did:web:api.agentvalet.ai#receipt-<kid> verification methods in the root DID document at /.well-known/did.json. The set is deliberately separate from the OAuth bearer key at /v1/oauth/jwks.json.

TypeScript (jose):

import { createRemoteJWKSet, jwtVerify } from "jose"; const JWKS = createRemoteJWKSet(new URL("https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json")); export async function verifyReceipt(jws: string) { const { payload, protectedHeader } = await jwtVerify(jws, JWKS, { issuer: "https://api.agentvalet.ai", typ: "agentvalet-receipt+jwt", algorithms: ["ES256"], }); return { audit_id: payload.jti, agent: payload.sub, kid: protectedHeader.kid, ...(payload.av as object) }; }

Python (PyJWT):

import jwt from jwt import PyJWKClient JWKS = PyJWKClient("https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json") def verify_receipt(jws: str) -> dict: if jwt.get_unverified_header(jws).get("typ") != "agentvalet-receipt+jwt": raise ValueError("not an AgentValet receipt") key = JWKS.get_signing_key_from_jwt(jws).key claims = jwt.decode(jws, key, algorithms=["ES256"], issuer="https://api.agentvalet.ai", options={"require": ["iss", "sub", "jti", "iat"]}) return {"audit_id": claims["jti"], "agent": claims["sub"], **claims["av"]}

Check alg, typ, iss and that kid is in the key set; then trust the payload.

Honest limits

  • A signature proves a row existed and what it said at decision time. It does not prove a row was never deleted; a signed daily checkpoint that would make deletion detectable is planned, not built. Do not read “receipt” as “tamper-evident chain”.
  • Receipts cover decisions made by the AgentValet proxy on /v1/actions, the git passthrough and approval replay. Events decided elsewhere (the open-core broker, Observe Mode, dashboard mutations) carry none.
  • Receipts are retained and purged with their audit row, per your plan’s retention window. Export them if you need them to outlive it.
  • The payload commitment covers what the call sends (method, endpoint, body, forwarded headers). Where it lands (the connection, a pinned account, the person a call runs for) has its own receipt fields and is re-checked at replay through the held call’s identity hash, but that hash is not in the receipt, so a third party can verify the payload offline but not the landing point.
  • The commitment proves which payload was approved and executed. It does not prove the approver read every field: see what approvers see.
  • A retired public key stays published only while it remains in the configured key set, so an old receipt keeps verifying; a key that is ever removed from the set orphans the receipts it signed.
Last updated on