Skip to main content

ziffer

The ZIFFER client for Python. Propose an action, wait for the decision, and verify the signed receipt in your own process before you act.

Install

pip install ziffer

Python 3.10 or later.

Quickstart

import json, os
from ziffer import Client, TrustAnchor, verify

client = Client(base_url=os.environ["ZIFFER_API_URL"], api_key=os.environ["ZIFFER_API_KEY"])
anchor = TrustAnchor.from_file(
    os.environ["ZIFFER_TRUST_ANCHOR"], min_suite=os.environ["ZIFFER_SUITE_FLOOR"]
)

decision = client.wait_for_receipt(client.propose(proposal).decision_id, timeout=30.0)
if decision.receipt is None:
    raise PermissionError(f"ziffer refused: {decision.refusal_category}")

verify(decision.receipt, json.dumps(proposal).encode(), anchor)
bank.transfer(amount, to_account)   # your line, unchanged

You pass the proposal twice on purpose. The verifier hashes the bytes you hand it and compares them with the receipt's claim. The check is against your copy, not against ours. Key order and spacing do not matter.

verify checks the answer. Your own if is what stops the action.

wait_for_receipt returns once the receipt can be read, or with the refusal, and never proposes again. client.wait returns earlier, at the decision, which can come before the receipt.

An action that needs human approval keeps wait_for_receipt waiting until a person approves, so give it minutes rather than seconds; past the timeout it raises WaitTimeout and the decision stays open. Its receipt carries the approvals: verify it with your registry, below.

Checking the approvals yourself

Hand verify your own attester registry and it checks who approved, not just that we said so:

from ziffer import AttesterRegistry

registry = AttesterRegistry.from_file(os.environ["ZIFFER_ATTESTER_REGISTRY"])

result = verify(decision.receipt, json.dumps(proposal).encode(), anchor, registry=registry)
if result.approvals:
    print("approved by", ", ".join(result.approvals))

ZIFFER_ATTESTER_REGISTRY is policy/attesters/registry.json in your own policy repository — the file that says who may approve and how many of them it takes. Every approval travelling with the receipt is checked against the keys in it. We hold none of those private keys, so a breach of ZIFFER cannot produce an approval your verifier will accept.

Two things to know before you rely on it. result.approvals is who approved, and it is empty for an action that needed nobody — we cannot tell you from the receipt alone whether an action should have needed approval, so if a call must never run without two people, check len(result.approvals) yourself. And if you leave registry out, a receipt that carries approvals is refused (AB-1) rather than passed unchecked; a receipt that carries none comes back with result.quorum_verified False, which says the approvals were not checked.

Configuration

Variable What it is Where the value comes from
ZIFFER_API_KEY Your API key. It carries your tenant, so no request names a tenant. We issue it. It expires after 90 days unless you ask for another lifetime.
ZIFFER_TRUST_ANCHOR Path to the public key file your receipts are signed under. We give you the file. Take it from us, never from the API you are checking.
ZIFFER_SUITE_FLOOR The weakest signature suite you will accept. You choose it. There is no default.
ZIFFER_ATTESTER_REGISTRY Path to policy/attesters/registry.json in your policy repository. Optional; without it a receipt that carries approvals is refused (AB-1), and one that carries none reports quorum_verified False. Your own repository. Never take it from us.
ZIFFER_API_URL The base URL of the ZIFFER deployment you call. We give it to you with your key.

client.whoami() returns an Identity: the tenant your key is bound to and the date it expires, so you can check which key your environment holds before the first refusal.

When a request is refused

Every refusal is a raised RefusedError whose name says what is wrong with the receipt (for example ReceiptNotBoundToProposal), whose clause is the rule it was refused under (9.3-3), and whose action says what to do; it prints as Name: what it means (rule). The table of every refusal is at https://ziffer.io/docs/refusals. Catch RefusedError, record the name and the rule, and do not retry it. An ApiError named GatewayUnreachable is a different thing: you never got an answer at all.

When a call does not get through

Some failures are worth another try and some are not, so the client decides for you.

Tried again: no answer at all — the connection was refused, DNS failed, or the round trip ran out of time — and the answers 429, 502, 503 and 504.

Not tried again: 500, and every 4xx except 429. A 400 or a 403 is about your request, and sending the same request twice changes nothing about it. A 500 is deliberately treated differently from a 502 or a 503: we say 502 or 503 when it is us that is unavailable or shedding load, which is an invitation to come back, so a 500 is a fault nobody classified and repeating it just repeats it.

When an answer asks us to wait a set number of seconds, the client waits exactly that and no longer. (A Retry-After we cannot read as a whole number of seconds — a date, a fraction — is treated as if it were not there.) Otherwise the client waits a random time up to half a second, then up to one, two, four. Random on purpose: clients that all back off by the same amount all come back at the same moment. At most five attempts, and every attempt sends the same bytes, so a retry can never reach us as a second, differently-spelled request.

One client will also only do so much of this. It has ten waits of its own to spend, and a successful call puts one back. When they are gone, a failure comes straight back to you instead of being retried — a bad afternoon stays a bad afternoon instead of becoming a flood.

Putting a limit on the whole call

client.propose(proposal, deadline=5.0)   # seconds from now

Before each wait the client checks whether it would still be waiting after your deadline. If it would, it stops there and raises DeadlineExceeded, which carries the name and the status of the last thing that went wrong — so you can tell a busy gateway from an unreachable one. Without a deadline there is no such check, and the five attempts are the limit.

client.wait(...) and client.wait_for_receipt(...) poll, and each poll is retried the same way, inside the time you gave the call. No poll will sleep past it.

Seeing what it did

client.retry_stats
# {'retries_directed': 2, 'retries_computed': 1, 'retry_bucket_level': 9}

retries_directed counts the waits we asked for; retries_computed counts the ones your client chose on its own. They are separate numbers because they tell different stories — the first is ZIFFER asking for room, the second is your client waiting out a silence. retry_bucket_level is how much of the allowance above is left.

Checking an audit chain download

The console's Download the audit chain for this window file checks here too:

python3 -m ziffer verify-chain ziffer-audit-example.json --anchor-key anchor-identity.pub

or verify_chain(file_bytes, AnchorKey.from_file("anchor-identity.pub")) from code. Every record's hash is recomputed, each anchor's signature is checked under the audit anchor key you hold (never the copy inside the file), and the verdict says which records no anchor covers yet. A break raises ChainRefused, whose name and seq say what broke and where. Integrating the SDK, section 6, has the whole of it.

Documentation

Support

Write to hello@ziffer.io. Your API key, your trust anchor file and your suite floor come from us. So does an answer about a refusal you cannot explain.

License

Proprietary. ZIFFER is a product and registered trademark of code75 SASU. Copyright (c) 2026 code75 SASU, Paris, France. All rights reserved. This package is not open source. Its use is governed by your agreement with code75 and by the LICENSE file in this package. The open-source components it redistributes are listed in THIRD-PARTY-NOTICES, under their own licences.

Metadata

Release files for ziffer 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ziffer 0.3.0
File Size Uploaded
ziffer-0.3.0.tar.gz 151.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ziffer 0.3.0
File Interpreter ABI Platform
ziffer-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 306.6 kB

Release files / ziffer-0.3.0.tar.gz

Download URL ziffer-0.3.0.tar.gz
Size 151.6 kB
Tags Source
SHA-256 checksum
How to use checksums
0bd545fd33dea7e64b232c7d37a3c598a7497d252a2c41d186a3a3088ebcaf35
BLAKE2b-256 checksum
How to use checksums
d24cd5e6430ee819d619a2bcfa64e8f975bb4f66a7a016c00869d28902832455
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / ziffer-0.3.0-py3-none-any.whl

Download URL ziffer-0.3.0-py3-none-any.whl
Size 155.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8db948b2f64e2c658e02fa77d940c8938ae9234cbfa372f1dfbc62a0054e70d6
BLAKE2b-256 checksum
How to use checksums
c84ebf31a9f19baff90e48ee57befa2fa5bcd4001a8139dcb2a4f40b27ac4d62
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page