Quickstart
Python
Get a decision, verify its receipt against the published keys, and watch a changed receipt fail. About ten minutes, most of it reading.
You need
Python 3.9 or later, and the cryptography package for Ed25519.
1. Create the files
Set up
python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt
requirements.txt
cryptography>=41,<46
quickstart.py
# Silvector quickstart for Python 3.9 or later.
# Run: pip install -r requirements.txt && python3 quickstart.py
import base64
import http.client
import json
import os
import sys
from urllib.parse import urlsplit
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
API = urlsplit(os.environ.get("SILVECTOR_API", "https://api.silvector.com"))
if API.scheme not in ("https", "http") or not API.netloc:
sys.exit("SILVECTOR_API must be an http or https URL")
def canonical(value):
"""Canonical JSON: keys sorted, no whitespace, integers only.
Keys sort by UTF-16 code unit, as the signer's JavaScript does, which differs from
Python's code point order for characters outside the Basic Multilingual Plane."""
if isinstance(value, float):
raise ValueError("receipts carry integers only")
if isinstance(value, dict):
keys = sorted(value, key=lambda k: k.encode("utf-16-be", "surrogatepass"))
return "{" + ",".join(canonical(k) + ":" + canonical(value[k]) for k in keys) + "}"
if isinstance(value, list):
return "[" + ",".join(canonical(v) for v in value) + "]"
if isinstance(value, str):
# JavaScript escapes a lone surrogate; json.dumps would emit it raw.
return "".join(f"\\u{ord(c):04x}" if 0xD800 <= ord(c) <= 0xDFFF else c for c in json.dumps(value, ensure_ascii=False))
return json.dumps(value)
def b64url(text):
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def request(path, body=None):
# A deny or step-up decision answers 403 or 401 with a receipt, so read the body whatever the status.
connection = (http.client.HTTPSConnection if API.scheme == "https" else http.client.HTTPConnection)(API.netloc, timeout=15)
try:
connection.request(
"GET" if body is None else "POST",
API.path.rstrip("/") + path,
body=None if body is None else json.dumps(body),
headers={"content-type": "application/json", "user-agent": "silvector-quickstart-python"},
)
return json.loads(connection.getresponse().read())
finally:
connection.close()
def verifies(receipt, jwk):
try:
Ed25519PublicKey.from_public_bytes(b64url(jwk["x"])).verify(b64url(receipt["sig"]), canonical(receipt["body"]).encode())
return True
except InvalidSignature:
return False
# 1. Ask for a decision. The response carries a signed receipt.
decision = request("/v1/check", {"method": "GET", "path": "/pricing"})
receipt = decision["receipt"]
# 2. Fetch the published keys.
keys = [k for k in request("/.well-known/jwks.json")["keys"] if k.get("kty") == "OKP" and k.get("crv") == "Ed25519"]
# 3. Verify with the one key whose kid equals the receipt's agent_kid. Other published
# keys sign other things, so never fall back to them.
key = next((k for k in keys if k.get("kid") == receipt["body"]["agent_kid"]), None)
verified = key is not None and verifies(receipt, key)
# 4. Change one field and check that the signature no longer holds.
tampered = {"body": dict(receipt["body"], outcome="deny" if receipt["body"]["outcome"] == "allow" else "allow"), "sig": receipt["sig"]}
tampered_verifies = key is not None and verifies(tampered, key)
print(f"receipt: {decision['receiptId']}")
print(f"outcome: {receipt['body']['outcome']} (rule {receipt['body']['rule_id']})")
print(f"key: {key['kid'] if key else 'none published for ' + receipt['body']['agent_kid']}")
print(f"verified: {'true' if verified else 'false'}")
print(f"tampered copy verifies: {'true' if tampered_verifies else 'false'}")
print("evidence grade: rung 2 Operational, confidence none, uncalibrated (assigned by the docs, not a signed field)")
sys.exit(0 if verified and not tampered_verifies else 1)
2. Run it
Run
python3 quickstart.py
3. Read the output
REPLAY Recorded 2026-11-07 against api.silvector.com. Your receipt id and timestamp will differ.
Output
receipt: b8d4f2ceb697b3904d50459c649d6fc220418387d87332d04ab47cf2bd32fa98
outcome: allow (rule allow.default)
key: edge-receipt
verified: true
tampered copy verifies: false
evidence grade: rung 2 Operational, confidence none, uncalibrated (assigned by the docs, not a signed field)
- Decide.
POST /v1/checkran the request shape through the public policy for tenanttryand returned the outcome, the rule and a signed receipt. - Fetch the keys.
GET /.well-known/jwks.jsonreturned the public Ed25519 keys. - Verify. The script rebuilt the canonical JSON of the receipt body and checked the signature with the one key whose
kidequals the receipt'sagent_kid. It never tries the other published keys: they sign other things, such as sessions, so a receipt that verifies under one of them is not a valid receipt. - Tamper. It flipped the outcome and checked again. The signature no longer holds, which is the point of a receipt.
4. The evidence grade of this receipt
A receipt is a rung 2 record: it shows which rule decided and that the record was not changed. It does not, by itself, say whether the sender was an AI agent.
Evidence grade
Rung 2 · Operational
- Covers
- Which rule decided this request and that the record was not changed after signing. Not whether the sender was an AI agent.
- Confidence
- None stated. No calibrated confidence applies to this record.
- Calibration
- Uncalibrated
- Known error rates
- None measured. Silvector publishes no detection error rate yet.
- Basis
- Deterministic decide() rule allow.default
- Ed25519 signature over the canonical receipt body, checked against the published JWKS
- Bias risks
- The decision is only as good as the facts it was given. A request with no signature claim is not_identified, which is not a finding that a person or an agent sent it.
- Next step
- To ask who was behind a series of requests, keep the receipts and escalate to rung 3 with at least two independent signal families. How escalation works
- Assigned by
- These docs, from the rung definitions. The grade is not a signed field of the receipt.
Notes for Python
- The Python standard library has no Ed25519, which is the one reason for the dependency.
- Keys sort by UTF-16 code unit, as the JavaScript signer sorts them. Python's default order is by code point, which differs for characters outside the Basic Multilingual Plane.
Troubleshooting
- 429 Too Many Requests. The public check endpoint allows 60 requests a minute per caller. Wait a minute.
- verified: false. A value in the receipt body changed after signing, or your verifier builds the canonical form differently: keys sorted by UTF-16 code unit, no whitespace, integers only, strings escaped as JavaScript's
JSON.stringifyescapes them. Whitespace, key order and escape style in the JSON you received do not matter; the verifier rebuilds the canonical form from the parsed values. - key: none published for … The key set has no key with the receipt's
agent_kid, so the receipt cannot be verified.pnpm --filter @ag47/api devpublishes test keys under thumbprint ids and shows this; runpnpm --filter @ag47/docs apiinstead. - Another API. Set
SILVECTOR_API, for example tohttp://127.0.0.1:8787afterpnpm --filter @ag47/docs apiin the repository. For the Workers quickstart, givewrangler devanother port with--portand pass the URL with--var SILVECTOR_API:http://127.0.0.1:8787.
Next
ConceptReceipt
Every field, and the invariants that hold.
TryDecision playgroundChange the request and watch the decision and receipt.
JobShow an auditor what your policy didHand over receipts anyone can check.
Source: apps/docs/quickstarts/python/. All quickstarts