Python

A Python package, and the contract underneath it.

There is a Python package, affix-io. It is distributed as source by AffixIO rather than published on public PyPI, so you install it with pip from the tree we give you. Under it the API is plain HTTPS and JSON, and everything a Python service needs beyond an HTTP client is already in the standard library. This page covers both routes, including the parts people usually get wrong.

Last checked against the running API on . Examples target Python 3.10 and newer.

Close view of a printed circuit board showing routed copper traces between components
The interesting work is in the routing, not the packaging. Photograph by Bladiblahh, CC BY-SA 3.0, cropped and desaturated.

The package, and where it comes from

affix-io is a real package, not a plan. Apache-2.0, Python 3.10 or newer, no runtime dependencies. It is not on public PyPI, so pip install affix-io from the public index will not find it. AffixIO distributes the source tree to customers, and you install from that tree. On the AffixIO host it sits at /var/www/vhosts/api.affix-io.com/packages/python-sdk/.

python3 -m pip install -e "/var/www/vhosts/api.affix-io.com/packages/python-sdk[dev]"

# or, from inside a copy of the tree
python3 -m pip install -e ".[dev]"

Three things come out of it. AffixClient calls the API for prove, verify, attest, gate, Merkle audit, spent state, evidence export and webhook management, with idempotency keys and rate limit headers already handled. AffixLight does local Affix Light prove and verify with HMAC-SHA256 under scheme affix-light-v1, matching @affix-io/sdk-light. verify_webhook_signature does the receiver check described further down without you writing the comparison by hand.

from affix_io import AffixClient, AffixLight, verify_webhook_signature

client = AffixClient(api_key="afx_...")
result = client.prove(
    "simple_yesno",
    fields={"claim": "eligible"},
    idempotency_key="retry-safe-key-1",
)
checked = client.verify("simple_yesno", result["proof"], idempotency_key="retry-safe-key-2")

Local UltraHonk proving is not in it and will not be. That path stays on Node.js. Affix Light proofs are HMAC-bound decisions rather than SNARKs, which the sdk-light page sets out in full.

If you would rather not carry a source dependency, the rest of this page is the same integration written against raw HTTPS with httpx and the standard library. Both routes speak to the same API and get the same answers.

A client worth keeping

Base URL https://api.affix-io.com, an API key in Authorization: Bearer or X-API-Key, JSON in and JSON out. The three things worth building in from the start are an idempotency key on writes, a 429 handler that respects Retry-After, and capturing X-Request-Id into your own logs so a support conversation has a shared reference.

import os
import time
import uuid
from typing import Any

import httpx

BASE = os.environ.get("AFFIX_API_BASE", "https://api.affix-io.com")


class AffixError(RuntimeError):
    def __init__(self, status: int, payload: dict[str, Any], request_id: str | None):
        self.status = status
        self.payload = payload
        self.request_id = request_id
        super().__init__(f"{status} {payload.get('error', 'error')}: {payload.get('message', '')}")


class Affix:
    def __init__(self, api_key: str, base: str = BASE, timeout: float = 30.0):
        self._client = httpx.Client(
            base_url=base,
            timeout=timeout,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "User-Agent": "affix-python-example/1.0",
            },
        )

    def close(self) -> None:
        self._client.close()

    def post(self, path: str, body: dict[str, Any], *, idempotency_key: str | None = None,
             attempts: int = 3) -> dict[str, Any]:
        headers = {"Idempotency-Key": idempotency_key or str(uuid.uuid4())}

        for attempt in range(1, attempts + 1):
            response = self._client.post(path, json=body, headers=headers)

            if response.status_code == 429 and attempt < attempts:
                time.sleep(float(response.headers.get("Retry-After", "1")))
                continue

            payload = response.json()
            if response.status_code >= 400:
                raise AffixError(response.status_code, payload, response.headers.get("X-Request-Id"))
            return payload

        raise AffixError(429, {"error": "rate_limited"}, None)

    def verify(self, body: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
        return self.post("/v1/verify", body, **kwargs)

    def gate(self, body: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
        return self.post("/v1/gate/verify", body, **kwargs)

Reuse one Affix instance for the life of the process. A new httpx.Client per call throws away connection reuse, and at ten requests per second per key that shows up quickly. requests.Session works the same way if you would rather not add httpx.

On the idempotency key

Send one on every prove and verify call and keep it stable across retries of the same logical operation. A replay inside 24 hours returns the original 2xx body with Idempotency-Replayed: true, which is what makes a retry safe after a timeout you cannot interpret.

Reading responses properly

Two status codes carry meaning that is easy to mistake for an infrastructure problem.

Statuses to handle deliberately
StatusWhat it meansWhat to do
409The digest was already spent. A successful verify consumes a proof, so a second presentation returns double_spend_detected with reason code DOUBLE_SPEND.Treat it as a decision. Never retry it, and surface it to the operator as a refused presentation rather than an error.
429Rate limited. Ten requests per second per key by default.Sleep for Retry-After and retry with the same idempotency key.

Every response carries X-Request-Id. Log it next to your own correlation id at the point of the call, not only in the error path, because the requests you need to explain later are usually the ones that succeeded.

Rate limit state arrives on every response in X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. If you run a batch job, read the remaining count and pace yourself instead of discovering the ceiling with a 429.

Reason codes

Verification outcomes come back with a reason code rather than prose: ADMITTED, VALID_CHECK, VALID_ALREADY_SPENT, INVALID_PROOF, DOUBLE_SPEND, EXPIRED, WRONG_GATE, POLICY_MISMATCH, DEVICE_UNKNOWN, DEVICE_REVOKED, REGION_MISMATCH, FACTOR_INCOMPLETE, QUORUM_INCOMPLETE, DELEGATE_OK, DELEGATE_EXPIRED, DELEGATE_UNKNOWN. Map them once, in one module, and keep the mapping out of your request handlers. The live list is at GET /v1/verify/reason-codes.

A webhook receiver that will not embarrass you

The one rule that catches people: hash the bytes you received, not the object you parsed. Most Python frameworks hand you a parsed body by default, and re-serialising it changes whitespace or key order, so the digest no longer matches.

import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

SECRET = os.environ["AFFIX_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300

app = Flask(__name__)


def signature_ok(raw_body: bytes) -> bool:
    try:
        ts = int(request.headers["X-Affix-Timestamp"])
    except (KeyError, ValueError):
        return False

    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False

    provided = request.headers.get("X-Affix-Signature", "")
    provided = provided.split("=", 1)[-1].strip().lower()

    message = f"{ts}.".encode() + raw_body
    expected = hmac.new(SECRET.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)


@app.post("/affix/webhook")
def receive():
    raw = request.get_data()          # bytes, before any parsing
    if not signature_ok(raw):
        return "", 401

    event = json.loads(raw)
    enqueue(event["delivery_id"], event)   # deduplicate on delivery_id
    return "", 204

Return quickly. Each attempt times out after eight seconds and a slow receiver is recorded as a failed one, so acknowledge first and do the work in a queue. Deliveries retry up to three times under the same delivery_id, which is your deduplication key. The webhooks page has the full event catalogue and payload fields.

Checking the audit tree yourself

A digest that AffixIO says is in the audit tree can be checked without trusting the answer. Ask for the inclusion proof, fold it locally, compare the result with the published root. Ten lines of hashlib, no dependency.

import hashlib
import hmac

import httpx

NODE_PREFIX = b"affix:node:"


def node_hash(a: str, b: str) -> str:
    lo, hi = sorted((a, b))
    return hashlib.sha256(NODE_PREFIX + lo.encode() + hi.encode()).hexdigest()


def inclusion_holds(leaf_hash: str, root: str, proof: list[dict]) -> bool:
    current = leaf_hash
    for step in proof:
        current = node_hash(current, step["sibling"])
    return hmac.compare_digest(current, root)


def check(digest: str, api_key: str) -> bool:
    headers = {"Authorization": f"Bearer {api_key}"}
    proof = httpx.get(f"https://api.affix-io.com/v1/merkle/proof/{digest}", headers=headers).json()
    published = httpx.get("https://api.affix-io.com/v1/merkle/root").json()["root"]

    return (
        inclusion_holds(proof["leaf_hash"], proof["root"], proof["proof"])
        and proof["root"] == published
    )

The interior hash sorts its two inputs before hashing, so the side field in each proof step does not change the outcome. It is in the response because readers expect it, and because a verifier written against a scheme that does not sort will still be correct if it honours it.

GET /v1/merkle/root needs no key at all, which is the point. Anyone holding a digest from your evidence pack can confirm it sits under the published root without an account and without learning anything about the person the check concerned. If you would rather not implement the fold, POST /v1/merkle/verify-proof does it server side, though that puts you back to trusting the answer.

Reading the evidence export

The export is newline-delimited JSON, so stream it rather than loading it. This matters at the ten thousand event ceiling, and it matters more when you run the same job every hour for a year.

import json
from datetime import datetime, timezone

import httpx


def pull(api_key: str, since: str, until: str | None = None):
    params = {"since": since}
    if until:
        params["until"] = until

    headers = {"Authorization": f"Bearer {api_key}"}
    with httpx.stream(
        "GET",
        "https://api.affix-io.com/api/export/siem",
        params=params,
        headers=headers,
        timeout=120.0,
    ) as response:
        response.raise_for_status()
        truncated = response.headers.get("X-Affix-Export-Truncated") == "1"

        for line in response.iter_lines():
            if line:
                yield json.loads(line)

    if truncated:
        raise RuntimeError("window exceeded the export ceiling, narrow it and pull again")


now = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
for event in pull(API_KEY, since="2026-08-01T00:00:00Z", until=now):
    if event["event"] == "verify" and event["detail"].get("decision") == "no":
        alert(event)

Keep the watermark on disk and overlap the window slightly. Full parameter list, the event catalogue and the field mapping worth doing at ingest are on the evidence export page.

What Python cannot do here

The package closes most of the old gap. What is left is worth being direct about, because the workaround is simple.

Local zero-knowledge proving

UltraHonk proving runs through Barretenberg inside @affix-io/sdk on Node.js 18 or newer. There is no Python binding, in the package or outside it.

  • Call the API prove endpoint, or
  • run the Node package as a small worker beside your Python service and speak to it over a local socket.

A public PyPI listing

The package exists, but it is not on the public index. You install from the source tree AffixIO gives you, which means upgrades arrive when you pull a new tree rather than from pip install --upgrade.

  • Pin the tree in your own artefact store if you need reproducible builds.
  • The Affix Light HMAC path is in the package, through AffixLight. Light proofs are not SNARKs: see the sdk-light page for what the mode guarantees.

Offline queue and auto-flush

Queueing proofs while a network is down and flushing them later is Node SDK behaviour. The Python package does not carry it, so you build the queue yourself.

  • Straightforward with any durable queue.
  • Anchor batches with POST /v1/merkle/audit/batch, up to a thousand leaves per call.

Carrier rendering

QR and barcode generation from a proof lives in the Node packages, which bundle the rendering libraries. The Python package has no runtime dependencies and does not draw anything.

  • Issue carriers through the API instead, for example POST /v1/token/issue or POST /v1/link/issue.
  • Render with whatever your Python stack already uses.

Getting the source tree

Ask for it at hello@affix-io.com and say which Python version and packaging setup you are on. The tree includes tests you can run before you trust it. If a public PyPI release would change how you deploy, say that too, because release decisions follow what customers actually need rather than what looks tidy.

Questions

The package, proving, retries and verification.

Is there an AffixIO Python package?

Yes, affix-io. Apache-2.0, Python 3.10 or newer, no runtime dependencies, giving you AffixClient, AffixLight and webhook verification. It is distributed as source rather than published on public PyPI.

How do I install it?

With pip, from the source tree: python3 -m pip install -e "/var/www/vhosts/api.affix-io.com/packages/python-sdk[dev]" on the AffixIO host, or the same command from inside your own copy. pip install affix-io against the public index will not fetch it.

Can Python generate a zero-knowledge proof locally?

No. Local UltraHonk proving runs through Barretenberg inside the Node package. From Python, call the API prove endpoint or run the Node package as a separate worker. Local Affix Light HMAC decisions are available through AffixLight, but those are not SNARKs.

How do I verify a webhook in Python?

Reject anything whose X-Affix-Timestamp is more than 300 seconds from now, compute HMAC-SHA256 over the timestamp, a full stop and the raw request bytes with the endpoint secret, and compare with hmac.compare_digest against the hex digest in X-Affix-Signature.

How should a client handle rate limits?

A 429 carries Retry-After alongside the X-RateLimit-* headers. Sleep for that interval and retry with the same Idempotency-Key, which makes the retry safe on prove and verify calls.

What does a 409 mean?

The digest was already spent. A successful verify consumes a proof, so a second presentation returns double_spend_detected with reason code DOUBLE_SPEND. Treat it as a decision and never retry it.

Can Python check the audit tree without trusting the API?

Yes. Fetch the inclusion proof, fold the leaf hash with each sibling using SHA-256 over the domain prefix and the two hex hashes in sorted order, then compare with the root published at GET /v1/merkle/root, which needs no key.

Next

Get a key, read the schema, or look at the surfaces a Python service usually wires up first.