Integrating the SDK
Who this is for: the engineer who owns the code that runs your AI agent's tool calls.
Who this is for: the engineer who owns the code that runs your AI agent's tool calls. You should be comfortable adding a dependency and reading an environment variable. You do not need to know anything about the protocol, and nothing here asks you to move a credential.
What you end up with: the tool handler you already have, plus about ten lines. Before it acts, it asks ZIFFER; it checks the answer's signature in your own process; then your existing execution line runs unchanged. ZIFFER never holds the credential the action is performed with, and never sees it.
Read section 7 before you plan around any of this. It is placed before the code on purpose.
A reader who takes
"the SDK verifies the receipt" to mean "the SDK stops the action" will have taken the opposite
of what it does. The SDK verifies. Your if stops the action.
1. The idea in one paragraph
Everything ZIFFER does ends in one artifact: a Decision Receipt, signed by our KMS, saying that a specific proposed action was allowed. Your handler proposes an action, waits for the decision, and then does the one thing that is the whole of it: checks the receipt's signature itself, in its own process, against a public key you configured out of band. If the check passes, the action was allowed by the policy your organisation signed. If it fails, you get a named refusal and you do not act.
The consequence worth stating: the gate is the receipt, not the network. A receipt that arrives over a perfect TLS connection is checked exactly as hard as one that does not. Nothing about who sent it, or over which link, weakens a single step. You are not trusting our API to tell you the truth; you are checking a signature we cannot forge without the key we hold, over bytes you recomputed yourself.
2. Two rungs, and the second one is the default
Rung 1: the raw HTTP API. Nothing installed. You POST /v1/proposals, you poll
GET /v1/decisions/{id}, you keep the receipt for your audit trail. What you are trusting is
TLS: that the host answering is ours, and that the outcome field it sent is true. That is a
real, defensible integration, and for a read-only or low-stakes tool it may be all you want.
Rung 2: the SDK. This is the default, and the difference is one line. The SDK does
everything rung 1 does and then verifies the receipt cryptographically inside your process. The
line that makes the difference is the verify call. It is worth being exact about what it buys,
because "we use the SDK" is otherwise a sentence with no content:
| what rung 1 trusts | what rung 2 checks instead |
|---|---|
the outcome field is what our engine decided | the receipt's signature verifies under your configured key, every primitive, never any |
| this receipt is about the action you proposed | the proposal hash is recomputed from your own bytes and compared to the receipt's |
| the receipt is current | its validity window is checked against your clock |
| the receipt is one we issued | a receipt of the wrong version, suite or decision domain is refused by name |
The third column that does not exist is "and then it refuses to run your action". There isn't
one. verify returns or it raises, and the branch is yours to write. Section 7 says why that
boundary is where it is.
Rung 3 is a separate Executor process. It is not the entry point: start with the SDK, as this guide describes.
3. What you need before you start
Three things, and only the first comes from us.
-
An API key. Format
zfr_followed by 43 characters. The key determines your tenant. You never put a tenant name in a request, and if a proposal body carries atenant_idthat is not the key's, the request is refused withTenantMismatchrather than quietly rewritten. That refusal exists because the proposal is signed material further down: a gateway that edited your body would have become its author.The key expires: 90 days by default, and never more than a year. Ask for a different lifetime when you ask for the key. An expired key gets exactly the answer a revoked one gets,
401 {"error":"ApiKeyUnknown"}, byte for byte: telling the two apart in a response would tell anyone guessing keys which strings were once credentials.The API tells you when the key ends, on every successful call. Each 2xx answer, the POST and the GET, carries
X-Ziffer-Api-Key-Expires: <RFC 3339, UTC>for the key that authenticated it. Both SDKs read it: for the last fourteen days,ziffer.ClientandZifferClientwarn once per process on the language's standard channel (warnings.warnwith theziffer.ApiKeyExpiringcategory;console.warn), naming the date and the rotation. The header is never on a refusal, so an expired key still answers exactly like an unknown one, which means the warning can only reach a process that is still making successful calls. Route it somewhere a person reads (-W error::ziffer.ApiKeyExpiringturns it into a failing CI job), and keep the date in your renewals list as well.To ask the key directly, call
whoami. It answers two things and no more: the tenant your key is bound to, and the instant it stops being accepted (the header's value, asked for rather than read off a response). It takes no argument, because the key is the question. A dead key, expired, revoked or never minted, is the oneApiKeyUnknownrefusal, and the three cannot be told apart here either.me = client.whoami() print(f"this key is {me.tenant_id}'s until {me.key_expires_at}")const me = await client.whoami(); console.log(`this key is ${me.tenant_id}'s until ${me.key_expires_at}`);Rotate with overlap: get the next key, deploy it, then have the old one revoked. Both keys are live at once, on purpose, and nothing revokes the old one for you. Ask us for a successor (we mint it carrying the same tenant and label, read from our own records rather than retyped); put it in
ZIFFER_API_KEYwherever your service runs; confirm your traffic is flowing under it; then tell us to revoke the old one. Doing it the other way round, revoke and then deploy, is an outage as long as your deploy takes, which is why the tooling on our side refuses to rotate and revoke in one step.What a stolen key buys, stated plainly. It is a bearer token: possession is the whole of the proof, it replays, and it sits in an environment variable. Someone holding it can submit proposals as your tenant and read the decisions and receipts for your tenant, and nothing else. It cannot change policy, release a held action, acknowledge a notice, read another tenant's anything, or mint another credential. It buys no execution at all: the credential that actually does things is yours and never leaves your infrastructure, so what an attacker gets is a graded decision and a receipt for a proposal they wrote themselves. That lasts until the key expires or you have it revoked, which is why the lifetime is 90 days and not a year.
-
Your trust anchor file. This is the public half of the identity that signs your receipts, written by
ziffer pubkey --key <keyfile> --out anchor.json. It is a small JSON document withed25519_pk_hexandmldsa65_pk_hexin it. Put it on the filesystem your service reads and pointZIFFER_TRUST_ANCHORat it.Get this file out of band, not over the API you are verifying. An anchor fetched from the service it is used to check is not an anchor; it is that service asserting its own identity, which is the one thing the signature was supposed to establish independently.
-
Your suite floor, in
ZIFFER_SUITE_FLOOR: the weakest signature suite you will accept. It is separate from the anchor file and it has no default. A suite name is a property of a signature, not of a key, so it is not in the key document; and a default here would be a minimum cryptographic strength that this library chose for you.Omit it and the SDK refuses to build an anchor at all. In Python,
TrustAnchor(pub)andTrustAnchor.from_file(path)with nomin_suiteraiseAnchorErrornamingMinSuiteRequired; in TypeScript,minSuiteis a required field of theTrustAnchorinterface and the compiler refuses the object. The three names it accepts areed25519,hybrid-ed25519-mldsa65andslhdsa128s. Sethybrid-ed25519-mldsa65unless you have a reason: a floor namingslhdsa128srefuses every receipt rather than accepting a lattice signature in its place.
4. The shape of the change
Here is a tool handler that transfers money. It has its own credentials, it knows how to do its job, and it is not going to stop doing it.
def transfer(amount, to_account):
bank.transfer(amount, to_account) # your line, your credentialRung 2 wraps that line and changes nothing else:
def transfer(amount, to_account):
proposal = {...} # 1 describe the action
decision = ziffer.propose(proposal) # 2 ask
decision = ziffer.wait_for_receipt(decision.decision_id) # 3 wait for the signed answer
if decision.receipt is None: # 4 refused? stop.
raise YourRefusal(decision.refusal_category) # your own exception type
verify(decision.receipt, # 5 check the signature
json_bytes(proposal), anchor) # -- raises if it does not hold
bank.transfer(amount, to_account) # 6 your line, unchangedSix lines around one. Two of them are the point:
Line 5 is the whole product. Delete it and you have rung 1 wearing rung 2's dependency: you
are back to believing the outcome field. Nothing warns you, no test fails, and the integration
still "works", which is exactly why it is called out here rather than left to be inferred.
The proposal is passed to verify a second time, and that is the point. verify recomputes
the hash from the bytes you hand it and compares the receipt's field against it. Handing it the
receipt's own copy of the hash would prove nothing at all: a transmitted identifier is a name for
a binding, not evidence of one. So the client does not remember your proposal for you: you
present it again, and the check is against your copy.
Key order and whitespace do not matter. verify parses your bytes and canonicalises them
itself (RFC 8785), because the hash is defined over the canonical encoding and not over whatever
spacing your transport used. json.dumps(proposal), JSON.stringify(proposal) and a pretty-printed
copy of the same object all produce the same hash. What must match is the object: one changed
field value is a ReceiptNotBoundToProposal refusal (9.3-3), correctly.
The proposal, in full
One shape, whichever rung you are on, and the gateway validates it against a published schema before anything else happens. Every field is required, and the two objects at the bottom are empty rather than absent when an action has no parameters: an optional field would give one action two encodings and therefore two hashes.
{
"schema_id": "transfer",
"schema_version": "1.0.0",
"schema_hash": "sha256:<the 64 hex characters of your registered schema>",
"fidelity": "F-HIGH",
"tenant_id": "<your tenant>",
"payload": {
"task_type": "transfer",
"operator": "<the principal the action runs for>",
"targets": ["bank-api"],
"params": { "amount": 4200, "to_account": "48812" },
"cidrs": {}
}
}schema_id is lower-case letters, digits, underscores and hyphens, at most 32 of them: a dot in
it is refused. schema_version is three numbers with dots between them. fidelity is F-HIGH
or F-LOW, it is restamped by the door from what the door itself verified, and your copy of it
decides nothing. targets are the names your policy's sensitivity table is keyed by, and a
target you never listed is graded at the highest tier rather than the lowest. Each value in
params is an integer or a string, and nothing else.
A body that misses any of that is refused as ProposalMalformed before your tenant, your
policy or your action is looked at. That refusal is the schema disagreeing with your bytes; it
is not a verdict on the action.
Line 3 can return before the receipt exists, and a high-risk action is when it does. An action your policy grades at or above its HIGH floor is not signed on the strength of the grading alone: it needs a quorum of approvers first. So the API answers immediately, and the answer is
status: "decided" outcome: "ATTEST" receipt: absentATTEST is the gate, not a refusal. It says the decision reached the attestation stage and
approvers are being presented with it. Do not treat it as a DENY and do not re-propose: the
proposal is already in flight, and a second one is a second action. The receipt is signed once the
quorum forms and attaches to the same decision on a later GET /v1/decisions/{id}.
Line 3 waits for the receipt, not for the decision. wait_for_receipt (Python) and
waitForReceipt (TypeScript) keep polling GET /v1/decisions/{id} until the receipt is present,
and return it on the same decision; on this path that is after the quorum forms. They come back
early only with a refusal, exactly as wait does: a DENY, or a decision carrying a
refusal_category. Past your timeout they raise the same WaitTimeout that wait raises, and
the decision stays open under its id. They never propose again. Watch the receipt, not the
outcome: outcome stays ATTEST on that decision, and the receipt appearing is the change.
wait still exists and still returns at the decision, which on this path is at once, with no
receipt; it is the wrong call for line 3.
A receipt for an approved action carries the approvals, and verify needs your attester
registry to check them. Without one, such a receipt is refused ApprovalsUnverifiable (AB-1) rather than passed
unchecked, because nothing could bind the approvals to anything. The registry is
attesters/registry.json from your own policy repository, never a copy we send you. In Python it is
verify(..., registry=AttesterRegistry.from_file(path)); in TypeScript it is the trust anchor's
quorum, read with loadAttesterRegistry(path). Sections 8 and 9 show both.
The receipt appears when the Executor's part is done: not when it was approved, and before your handler acts. This is worth being exact about, because it is the one place where "decided" and "done" are different things and the API says both.
A receipt is our statement that a decision completed: the decision was handed to the Executor,
which verified it, wrote it into your audit chain, sent whatever notice was owed, held it if your
policy says so, and recorded it in its effect log. Only then does the receipt attach to your
GET. In the shape this guide describes, your handler performs the action itself, after verify
(line 6 of the wrap): the Executor holds no credential of yours and its effect is that log entry,
not your action. An ALLOW you read before the receipt is a decision, and a decision is not an
event. The same wait applies to an ALLOW that needed nobody: decided can come before the
receipt, and wait_for_receipt / waitForReceipt keep polling until it is present, so line 5
always has a receipt to check.
Three things follow, and none of them is a workaround:
- A held action carries
held_untiland no receipt. An action that needed approval is held for your policy's hold window after the approval, so the people notified can stop it; no receipt is readable until it releases, and the receipt you then read is the release receipt, signed at release with its own validity window. If the hold ends in a refusal, the decision carries arefusal_categoryand never a receipt — the shape a DENY already has. - A missing receipt is not an error and not a refusal.
status: "decided"with noreceiptmeans the decision is made and the Executor's part has not finished. Keep polling. It is the same answer shape you already handle while a quorum forms. - If the Executor refuses the action, no receipt ever appears. The outcome stays
ALLOW, because that is what we decided; there is simply nothing to hand you, because nothing happened. The clause the Executor refused under is in your audit chain and in the console. We will not guess on its behalf. Line 3 then ends at your timeout withWaitTimeout, and your handler does not act. - Do not treat a receipt's absence as permission to retry. Re-sending the same proposal is refused: one proposal, one receipt. If you genuinely want the action attempted again, that is a new proposal, because it is a new action.
5. What verify refuses, and what a refusal means
Every refusal carries a name that says what is wrong with the receipt, and the rule of
the specification it was refused under: name and clause on the refusal, in both SDKs. Both SDKs
print the same line for the same refusal, Name: what it means (rule), and spell every rule
identically to the Rust engine; one shared corpus of vectors is run by the Python test suite and by
the TypeScript one, over the same inputs. Each rule links to its section of the specification.
| refusal | what it means | what you do | Rule |
|---|---|---|---|
ReceiptVersionUnsupported | What you passed as the receipt is not a version 3 receipt. | Pass the receipt exactly as the decision API returned it, unchanged. If you did, report it with the receipt. | AB-0 |
ReceiptTooLarge | The receipt's signed body is larger than the 4,096 bytes a receipt is signed within. | Do not act on the receipt and do not retry. Report it with the receipt. | AB-6 |
SuiteUnknown | The receipt names a signature suite this verifier does not know or names none, or the suite floor you configured is not a suite it knows. | Check the spelling of the suite floor you configured (ZIFFER_SUITE_FLOOR), then update the SDK. If both are right, report it with the receipt. | CR-1 |
SuiteBelowFloor | The receipt, or an approval sent with it, is not signed with a suite that contains every algorithm your suite floor requires. | Confirm the suite floor you configured is the one you meant. If it is, do not act on the receipt and report it with the receipt. | CR-4 |
NotCanonicallyEncodable | The receipt, an approval or the proposal you passed holds a value with no canonical encoding, such as a fractional number, or the proposal bytes are not UTF-8 JSON. | Pass the exact proposal bytes you sent and the receipt exactly as it was returned. Remove any step that re-encodes either one on the way. | AT-8a |
SignatureInvalid | The receipt's signature does not verify under your trust anchor's keys. | Do not act on the receipt and do not retry. Confirm your trust anchor is the one on your handover sheet, then report it with the receipt. | 9.3-1 |
DecisionNotAllow | The receipt records a decision other than ALLOW. | Do not run the action. Report it with the receipt. | 9.3-2 |
ReceiptNotBoundToProposal | The receipt is not bound to the proposal you passed. | Pass the exact proposal this receipt answered. When several calls are in flight, match each receipt to its own proposal before verifying. | 9.3-3 |
ReceiptTimeUnreadable | The receipt's issue or expiry time is missing or is not an RFC 3339 UTC instant. | Do not act on the receipt. Pass it exactly as the decision API returned it; if you did, report it with the receipt. | 9.3-5 |
ReceiptExpired | Your clock is past the receipt's expiry time. | Keep the verifying host's clock on UTC time, and verify a receipt as soon as it arrives: it is valid for 120 seconds. Propose the action again for a new receipt. | 9.3-5 |
ReceiptIssuedInFuture | The receipt's issue time is more than 5 seconds ahead of your clock. | Synchronise the verifying host's clock with UTC time, then verify the receipt again. | 9.3-5 |
ValidityWindowTooLong | The receipt is valid for longer than the 120 seconds a receipt may be. | Do not act on the receipt and do not retry. Report it with the receipt. | L-14 |
NonceMalformed | A nonce in the receipt, or in an approval sent with it, is not b64: followed by padded base64. | Do not act on the receipt and do not retry. Report it with the receipt. | WE-4 |
ReceiptNonceWrongSize | The receipt's nonce is well formed but is not 128 bits long. | Do not act on the receipt and do not retry. Report it with the receipt. | L-17 |
ApprovalsUnverifiable | The approvals sent with the receipt cannot be matched to the list the receipt signed: a list is malformed, an approval is not on it, or the verifier was given no attester registry. | Give the verifier your attester registry (ZIFFER_ATTESTER_REGISTRY, or the registry argument). If you did, do not act on the receipt and report it with the receipt. | AB-1 |
ApprovalDigestsUnsorted | The receipt's list of approval digests is not in sorted order. | Do not act on the receipt and do not retry. Report it with the receipt. | AB-2 |
ApprovalDigestsDuplicated | The receipt's list of approval digests names one approval more than once. | Do not act on the receipt and do not retry. Report it with the receipt. | AB-3 |
ApprovalCountMismatch | The number of approvals sent with the receipt is not the number of approvals the receipt signed. | Pass the receipt and its approvals exactly as the decision API returned them. If you did, report it with the receipt. | AB-4 |
ApprovalsMissing | The receipt commits to approvals and none were sent with it. | Pass the receipt with its attestations exactly as the decision API returned them. If you did, report it with the receipt. | INV-1-HIGH |
ApprovalObjectMissing | An approval sent with the receipt carries no approval object. | Do not act on the receipt and do not retry. Report it with the receipt. | AT-8 |
ApprovalObjectMalformed | An approval object lacks a required field, carries a field that is not defined, or holds a field of the wrong type. | Do not act on the receipt and do not retry. Report it with the receipt. | AT-8b |
ApprovalNonceWrongSize | An approval's nonce is well formed but is not 128 bits long. | Do not act on the receipt and do not retry. Report it with the receipt. | AT-1 |
ApprovalSignatureInvalid | An approval's signature does not verify against the key your attester registry holds for that approver, or your registry does not enrol that approver. | Check that the approver is enrolled in your attester registry with their current key. If they are, do not act on the receipt and report it with the receipt. | 9.3-7b-i |
ApprovalNotBoundToProposal | An approval sent with the receipt is not bound to the proposal you passed. | Do not act on the receipt and do not retry. Report it with the receipt and the proposal. | 9.3-7b-ii |
ApprovalContextMismatch | An approval names another policy version than the receipt, expired before the receipt was issued, or has an unreadable policy, expiry or operator field. | Propose the action again so that it is approved under the current policy. If it happens again, report it with the receipt. | 9.3-7b-iii |
ApprovalsDisagreeOnOperator | The approvals sent with the receipt name different operators. | Do not act on the receipt and do not retry. Report it with the receipt. | 9.3-7b-iii-a |
ApprovalQuorumSizeMismatch | An approver consented to a number of required approvals other than the quorum your attester registry sets. | Propose the action again so that it is approved under the current policy. If it happens again, report it with the receipt. | AT-9 |
ApprovalIdMismatch | An approval's transmitted identifier is not the identifier computed from its content. | Do not act on the receipt and do not retry. Report it with the receipt. | Y1b |
ApproverAssuranceTooLow | An approver is enrolled at an assurance level below your attester registry's floor, or at a level outside AS0 to AS2. | Enrol the approver at the assurance level your registry requires, or have an approver enrolled at that level approve instead. | AT-10 |
QuorumNotMet | Fewer distinct approvers approved the action than the quorum your attester registry sets. | Do not run the action. Check your registry's approvers and roles against who approved; propose the action again once enough approvers can answer. | AT-3 |
OperatorApprovedOwnAction | The operator who proposed the action is counted among its approvers. | Do not run the action. Have approvers other than the operator approve it. | AT-2 |
ApproverKeyTypeMismatch | An approval claims a kind of key, a person's passkey or a machine key, that is not the kind your attester registry enrols for that approver. | Do not act on the receipt and do not retry. Report it with the receipt. | WebauthnAlgMismatch |
PasskeyCounterNotAdvanced | A passkey or security key's signature counter did not advance, so the credential was copied or the approval was replayed. | Do not act on the receipt. Treat the approver's authenticator as compromised and re-enrol it before it approves anything else. | WebauthnCounter |
ApprovalMalformed | A person's approval, or the key your attester registry holds for them, is not in the shape a passkey approval takes. | Check the approver's entry in your attester registry against the credential they enrolled. If it matches, report it with the receipt. | Malformed |
PasskeyAssertionTypeInvalid | A person's approval is not a passkey sign-in assertion. | Ask the approver to approve again from your approval page. | WebauthnType |
PasskeyChallengeMismatch | A person's approval answers a different request than this approval. | Do not act on the receipt. Ask the approver to approve again from your approval page; if it happens again, report it with the receipt. | WebauthnChallenge |
PasskeyOriginMismatch | A person's approval was made on a site other than your approval page. | Ask the approver to approve again from your own approval page. If they did, report it with the receipt. | WebauthnOrigin |
PasskeyAuthenticatorDataInvalid | A person's approval names another site, or does not show that the person was present and verified. | Ask the approver to approve again and to complete the fingerprint, face or PIN step their authenticator asks for. | WebauthnAuthenticatorData |
PasskeySignatureInvalid | A person's approval signature does not verify against the passkey or security key your attester registry holds for them. | Check that the approver's entry in your attester registry holds their current credential. If it does, do not act on the receipt and report it with the receipt. | WebauthnSignature |
Branch on the refusal, and log its name and rule. Each verifier stops at the first check it reaches,
and the two SDKs do not order the floor check and the signature-shape check the same way: a
receipt that is both below your floor and carrying a signature whose primitives are not its
declared suite's is SuiteBelowFloor (CR-4) from the Python SDK and SignatureInvalid (9.3-1)
from the TypeScript one. Both refuse.
A refusal is not a network error, and you should not retry it. A receipt that fails one of these fails it deterministically; asking again gets the same answer with an extra round trip. Treat it as you would a failed authorisation: stop, and record the name and the rule.
The one refusal in this section that is worth retrying, and it is not verify's
| the decision is refused when | clause (console) | refusal_category (API) | what you do |
|---|---|---|---|
| the receipt's policy basis is not the bundle the Executor holds — the window right after you publish policy | 9.3-4 | PolicyBasisMoved | retry once, after the activation window. This is not a policy refusal |
The API never tells your code the clause; it tells it whether to try again.
Every decision route — propose, decision, wait and list — carries a refusal_category
instead of a clause: PolicyRefused (a rule said no: never retry), PolicyBasisMoved (retry
once after activation), RateBounded (retry later) or ProposalMalformed (fix the submission).
Both SDKs hand it to you verbatim as a typed field beside outcome and refuse any other value
rather than guess. The clause is still recorded, in your audit chain and in the console, where a
signed-in person reads it; the caller holding your API key may be the AI agent you are containing, and
a clause served one decision at a time maps your rules as surely as a list of them would. The
support guide's section 4 has a row for each category.
Everything above this row is your own verify refusing a receipt in your process. This one is
the deployment refusing, and it has a cause you can plan around: activation is not
instantaneous. When your CI publishes a new policy, each process that reads policy picks it up
on its own poll — 15 seconds by default — so for up to two intervals, 30 seconds at the
default, an Executor can still hold epoch N while the engine has already graded under N+1.
The receipt then names a policy basis the Executor does not have, and it refuses at 9.3-4
rather than executing under a rule set it cannot see. That is the control working.
Two things follow, and the second is the one people get wrong:
- Retry once, after the window. The same proposal under the settled epoch gets a receipt that verifies. A single retry after 30 seconds is the whole handling.
- Do not treat it as
ALLOWand do not treat it as a rule saying no. It is neither: it is two halves of the deployment being momentarily one epoch apart.policy-ci.mdsection 7 has the publisher's side — your workflow printsactivewhen every reader has the new bundle, and that is the moment the window closes.
9.3-4 outside that window means something else: an Executor that never picked up a bundle, or
a receipt from a different deployment. If it persists after a publish has printed active,
stop and report it with the receipt.
What verify does not check is as important as what it does. It runs the stateless half
of the checklist: the part answerable from a receipt, a proposal and a key. It does not and
cannot check that this receipt has not already been used, because that is a claim against a
durable ledger and there isn't one in your process. Section 7.
6. Seeing everything we are holding for you
propose hands you one locator and wait follows one. Neither answers the question an operator
asks at 9 a.m.: what is ZIFFER holding for us right now, and what did it decide overnight?
GET /v1/decisions?since=<RFC 3339 UTC>&limit=<1..200>&cursor=<opaque>One page, newest first, of everything recorded under your API key's tenant. There is no parameter for the tenant and there never will be: the key decides, and a request that names one is refused. A reply looks like this.
{
"items": [
{
"decision_id": "01k5r8z9k2c4d5e6f7g8h9j0k1",
"status": "decided",
"outcome": "ATTEST",
"receipt": "absent",
"created_at": "2026-09-21T18:00:00Z",
"waiting": true,
"expires_at": "2026-09-21T19:00:00Z"
},
{
"decision_id": "01k5r8yq7m3b2v1c0x9z8y7w6v",
"status": "decided",
"outcome": "ALLOW",
"receipt": "attached",
"created_at": "2026-09-21T17:55:00Z",
"waiting": false
}
],
"next_cursor": null
}waitingis the one you came for: a human has been asked and has not answered yet, andexpires_atis when that request stops being answerable. It is not the same asoutcome: "ATTEST", which only says a quorum was asked for — after the approval lands, or the window closes, the outcome still readsATTESTandwaitinggoes false.receiptis a word, not the document. Fetch the document withGET /v1/decisions/{id}, which is the one route that serves receipts.outcomeis absent whilestatusispending. An absent member is absent, nevernull.next_cursorisnullat the end and is always present. Hand it back unchanged; do not build one. A full page always carries one, so a list whose length is an exact multiple of yourlimitcosts one more call that comes back empty.sincedefaults to 24 hours ago, is strict RFC 3339 UTC — exactly2026-09-21T18:00:00Z, no offset, no fraction, no lowercasez— and may not reach further back than 730 days, which is the retention clause 7.7 of the Agreement commits to.limitis 1..200, default 50.- Any bad parameter is one refusal:
ListQueryMalformed, HTTP 400. It does not say which parameter, for the same reason as the next paragraph.
This route carries no reasons, and that is deliberate. No clause id, no rule name, no refusal
detail — on any item, ever. The caller holding your API key may be the AI agent you are trying to
contain, and a list it can sweep is an oracle over your own signed policy: run enough proposals,
read enough clauses, and you have mapped the rules you are being judged under. What was decided
is yours to read at the API; why is in your audit trail, and for a signed-in human it is in the
console. If you find yourself wanting the clause here, that is the control working. What an item
does carry is refusal_category for a refused decision — whether to try again, never which rule —
and held_until while an approved action is held.
Python
page = client.list(since="2026-09-21T00:00:00Z", limit=50)
for item in page.items:
if item.waiting:
print(f"{item.decision_id} is waiting on a human until {item.expires_at}")
while page.next_cursor is not None:
page = client.list(cursor=page.next_cursor)TypeScript
let page = await client.list({ since: '2026-09-21T00:00:00Z', limit: 50 });
for (const item of page.items) {
if (item.waiting) {
console.log(`${item.decision_id} is waiting on a human until ${item.expires_at}`);
}
}
while (page.next_cursor !== null) {
page = await client.list({ cursor: page.next_cursor });
}Both go through the same retry path as every other call, so a gateway shedding load does not turn a list into a failure.
Checking the audit chain you download
The console's audit page has a link, Download the audit chain for this window, verifiable
offline. The file holds every record of that window, each with the hash that chains it to the
record before, and the anchors: signatures our audit service publishes over the latest hash of
your chain, and of every other customer's, at intervals. Once a record is covered by an anchor,
changing it changes a hash the anchor has already signed. Checking the file needs nothing from us
at the moment you check it. The check is verifyChain in @ziffer-io/verify, and section 9 has
it from code.
What it checks. Every record's hash is recomputed from the record itself and the hash before
it, and the first one that does not match is refused, naming the record: ChainHashMismatch (the
record is not the one that was chained), ChainLinkBroken (it does not continue from the record
before it), ChainSeqGap (a record is missing from the middle), RecordOutOfPlace (it names
another customer or another position), RecordNotCanonical (it holds a number that cannot be
encoded one way). Then every anchor: both halves of its signature must verify under the anchor
key you pass (AnchorSignatureInvalid, AnchorSuiteNotEnrolled), and the hash it signed for
your chain must be the hash the file recomputes to at that record (AnchorHeadMismatch). That last
one is refused even without a key, because it is exactly what a history rewritten after it was
anchored looks like.
What the anchors do not cover, it tells you. anchored_through is the last record a verified
anchor ties to this file. The records after it, from unanchored_from on, are chained, but
nothing outside our own storage commits to them yet. Anchors are published on a schedule, so the
newest records of a download are usually in that state; download the same window again after the
next anchor to cover them.
The anchor key is not your trust anchor, and it never comes from the file. anchor.json names
the key that signs your receipts; audit anchors are signed by a different key on purpose, so that
one key's compromise is not both, and passing anchor.json as the anchor key is refused. Every
anchor in the download also carries a copy of its signing key beside it. The check never reads
that copy: a file that brings the key it is checked with proves only that whoever wrote it could
sign. Leave the key out and the chain is still recomputed end to end, while every anchor is
reported unchecked, which means nobody has checked who signed it. The anchor key is
anchor-identity.pub, one of the files we hand you at enrolment, beside anchor.json. Before you
rely on it, compare its fingerprint with the one we read to you on a second channel, the way you
obtained your trust anchor: a key that reached you only in the same message as the files it checks
is checked against nothing. Pass it to parseAnchorKey, over the file's bytes (section 9).
Check that the file runs to the end of its window yourself. A file cut short at the end still
verifies, and unanchored_from is where the part no anchor commits to begins.
7. What stays your code's job
Read this section twice. Everything above is true and none of it adds up to "the SDK stops your AI agent".
-
verifyreturns; it does not intervene. It is a function that raises or returns a value. If your handler calls it and ignores the result, the action runs. If your handler never calls it, the action runs. Nothing in this library sits between your AI agent and its credentials. That is rung 3's shape, and even there it is a separate process you route through, not a hook that catches you. The gate is a line of your code, and you have to write it. -
Make your execution idempotent. The claims that make a receipt single-use are made against a ledger inside our deployment perimeter. Your process cannot reach it. So a receipt your handler verified once will verify again, and again: if an attacker can get your handler to run twice with the same receipt,
verifywill not be what stops them. Make your own execution idempotent, on your own key, the way you would without us. -
The MCP server routes; it does not enforce. The MCP server (section 10) lets an AI assistant ask ZIFFER things and read this guide while it writes your integration. It runs on the developer's machine, at development time, and it has no hands on your production path. MCP alone routes the question; only the SDK's
verifyline enforces the answer. An AI agent that has talked to the MCP server has not thereby been gated by anything. -
The trust anchor's freshness is yours to manage. The SDK reads the key file you point it at and has no idea whether that key was rotated last week. When we rotate it, we tell you, and you replace the file.
-
Nothing here is an audit trail. Keeping the receipt is your side of it. The SDK does not store, forward or log receipts, and a verified receipt you threw away is a check you can no longer show anyone.
-
A sandbox is a separate tenant, not a dry run. If you were given sandbox credentials, they are a different API key and a different trust anchor, because a sandbox tenant signs its receipts under its own identity, which is exactly what makes a sandbox receipt fail your production
verify(SignatureInvalid,9.3-1) rather than needing a flag somebody could forget. Its decisions run the real path and are approved by a robot with no human in it, so anALLOWthere means the path worked, never that anyone agreed. And it changes nothing about your code: your handler still performs the action, so a sandboxALLOWhanded to a handler that transfers money transfers money. The Running a sandbox tenant guide states what the sandbox marker guarantees; the integration itself is the same in both, which is the point of having one.
8. Python
Install ziffer 0.3.0 or later (0.2.0 has no wait_for_receipt), and read four environment variables.
wait_for_receipt polls GET /v1/decisions/{id} until the receipt is present, the decision is
refused, or the timeout elapses.
import json
import os
from ziffer import Client, RefusedError, TrustAnchor, verify
client = Client(
base_url=os.environ["ZIFFER_API_URL"],
api_key=os.environ["ZIFFER_API_KEY"], # the key IS the tenant
)
anchor = TrustAnchor.from_file(
os.environ["ZIFFER_TRUST_ANCHOR"], # ziffer pubkey output
min_suite=os.environ["ZIFFER_SUITE_FLOOR"],
)
def transfer(amount: int, to_account: str) -> None:
proposal = {
"schema_id": "transfer",
"schema_version": "1.0.0",
"schema_hash": SCHEMA_HASH, # your schema's hash, fixed at build
"fidelity": "F-HIGH",
"tenant_id": TENANT_ID, # must equal the key's tenant
"payload": {
"task_type": "transfer",
"operator": OPERATOR, # the principal the action runs for
"targets": ["bank-api"],
"params": {"amount": amount, "to_account": to_account},
"cidrs": {},
},
}
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}")
try:
verify(decision.receipt, json.dumps(proposal).encode(), anchor)
except RefusedError as refusal:
# refusal.name is the refusal, refusal.clause its rule -- log both, do not retry.
raise PermissionError(f"receipt refused: {refusal.name} ({refusal.clause})") from refusal
bank.transfer(amount, to_account) # your line, unchangedjson.dumps is fine here. verify parses the bytes and canonicalises them itself, so key
order and spacing are not your problem. The SDK does not export a canonicaliser and does not need
to.
A ValueError from verify is not a refusal. If the bytes are not JSON at all, verify
raises ValueError, deliberately: a broken integration must not be mistaken for a caught attack.
Only RefusedError means the receipt did not hold.
Catch RefusedError, not Exception. A refusal means the receipt did not hold. A
ConnectionError from propose means you never got one. Collapsing the two into a single
except turns "our gateway was briefly unreachable" into "the policy denied this", and you will
debug the wrong thing.
An action that needed approval is waited on by the same call: wait_for_receipt keeps
waiting while it is held, and returns once the approvers have approved and the hold has released.
People are slow, so give it a timeout measured in minutes; past it, WaitTimeout is raised and the
decision stays open under its id. Its receipt carries the approvals, so verify needs your own
attester registry (section 4):
from ziffer import AttesterRegistry
registry = AttesterRegistry.from_file(os.environ["ZIFFER_ATTESTER_REGISTRY"]) # your attesters/registry.json
decision = client.wait_for_receipt(client.propose(proposal).decision_id, timeout=900.0)
if decision.receipt is None:
raise PermissionError(f"ziffer refused: {decision.refusal_category}")
result = verify(decision.receipt, json.dumps(proposal).encode(), anchor, registry=registry)
print("approved by", ", ".join(result.approvals))
bank.transfer(amount, to_account) # your line, unchangedresult.approvals names the approvers whose signatures verified under the keys in your registry.
Leave registry out and this receipt is refused ApprovalsUnverifiable (AB-1); a receipt that carries no approvals comes
back with result.quorum_verified False, which says nothing was checked rather than that nobody
approved.
9. TypeScript
The npm packages are @ziffer-io/client,
@ziffer-io/verify, @ziffer-io/types and @ziffer-io/mcp. npm install @ziffer-io/client is
the whole of it; install.md section 4 has the install for every machine. waitForReceipt,
loadTrustAnchor and loadAttesterRegistry need 0.3.0 or later. The Python package is on PyPI:
pip install ziffer; wait_for_receipt needs 0.3.0 or later.
@ziffer-io/client re-exports the verifier, so there is one verifyReceipt in your
dependency tree rather than two implementations that can disagree, and it re-exports the two
readers of your own files: loadTrustAnchor for the anchor file ziffer pubkey writes, and
loadAttesterRegistry for attesters/registry.json. canon comes from @ziffer-io/verify, its
own home. The client re-exports the verification surface it needs you to have, not the whole of
somebody else's package.
import { ZifferClient, loadTrustAnchor, verifyReceipt, Refusal, type TrustAnchor, type WireProposal } from '@ziffer-io/client';
import { canon } from '@ziffer-io/verify';
function env(name: string): string {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is not set`);
return value;
}
const client = new ZifferClient(env('ZIFFER_API_URL'), env('ZIFFER_API_KEY'));
// Reads the file `ziffer pubkey` wrote; refuses a missing, malformed or secret-key file by name.
const anchor: TrustAnchor = await loadTrustAnchor(env('ZIFFER_TRUST_ANCHOR'), env('ZIFFER_SUITE_FLOOR'));
async function transfer(amount: number, toAccount: string): Promise<void> {
const proposal: WireProposal = {
schema_id: 'transfer',
schema_version: '1.0.0',
schema_hash: SCHEMA_HASH,
fidelity: 'F-HIGH',
tenant_id: TENANT_ID,
payload: {
task_type: 'transfer',
operator: OPERATOR,
targets: ['bank-api'],
params: { amount, to_account: toAccount },
cidrs: {},
},
};
const submitted = await client.propose(proposal);
const decision = await client.waitForReceipt(submitted.decision_id, { timeoutMs: 30_000 });
if (decision.receipt === undefined) {
throw new Error(`ziffer refused: ${decision.refusal_category}`);
}
try {
verifyReceipt(decision.receipt, canon(proposal), anchor);
} catch (error) {
if (error instanceof Refusal) {
// error.name is the refusal, error.clause its rule -- log both, do not retry.
throw new Error(`receipt refused: ${error.name} (${error.clause})`);
}
throw error;
}
await bank.transfer(amount, toAccount); // your line, unchanged
}process.env is string | undefined, so the four values go through env. The client's
constructor takes strings; passing the environment straight in is a type error under TypeScript's
strict settings, and an as string there would be a second definition of the wire
type. The helper throws at start, where a missing variable is a deployment mistake you can see.
instanceof Refusal, never a cast. A catch binds unknown, and the honest reason to
narrow rather than assert is that this catch also sees the errors you did not plan for. An
as Refusal here would read a clause off a TypeError and report a protocol refusal that
never happened.
canon here, json.dumps in the Python example, and both are correct. verifyReceipt parses
your bytes and canonicalises them itself, so any JSON spelling of the same object gives the same
hash. canon is used here only because @ziffer-io/verify exports it; new TextEncoder().encode( JSON.stringify(proposal)) verifies identically. Bytes that are not UTF-8 JSON at all are refused
as NotCanonicallyEncodable (AT-8a), which is a bug in your call rather than a verdict on the receipt.
For an action that needed approval, the anchor needs quorum. It is a QuorumPolicy
(exported as a type by @ziffer-io/verify):
quorumK, attesters (a map from identity to its credential), assurances, minAssurance,
policyBundleHash and bundleEpoch, every one from your own signed bundle. Without it, a receipt
that carries approvals is refused ApprovalsUnverifiable (AB-1). loadAttesterRegistry reads the first four out of
attesters/registry.json in your policy repository, and refuses a registry that is malformed,
names one key holder twice, or holds a weak key, by the same names the Python SDK uses. The last
two name the policy version the approvals were given under, and the registry file does not carry
them: policyBundleHash is the tree hash ziffer list prints for that bundle and bundleEpoch
is its bundle_epoch. waitForReceipt keeps waiting while the action is held, so give it
minutes rather than seconds:
import { loadAttesterRegistry } from '@ziffer-io/client';
const registry = await loadAttesterRegistry(env('ZIFFER_ATTESTER_REGISTRY')); // your attesters/registry.json
const heldAnchor: TrustAnchor = {
...anchor,
quorum: { ...registry, policyBundleHash: POLICY_BUNDLE_HASH, bundleEpoch: BUNDLE_EPOCH }, // from `ziffer list`
};
const decision = await client.waitForReceipt(submitted.decision_id, { timeoutMs: 15 * 60_000 });
if (decision.receipt === undefined) {
throw new Error(`ziffer refused: ${decision.refusal_category}`);
}
const result = verifyReceipt(decision.receipt, canon(proposal), heldAnchor);
console.log('approved by', result.quorum?.approvals.join(', '));The module ziffer-scan --code writes takes those two values from the receipt's own signed
body instead, as the Python SDK does. That proves the approvals and the receipt name one policy
version; taking them from ziffer list, as above, also proves it is the version you published.
Checking an audit chain download, under the anchor-identity.pub we handed you at enrolment
(section 6 says what it checks, and why you compare its fingerprint first):
import { readFileSync } from 'node:fs';
import { parseAnchorKey, verifyChain } from '@ziffer-io/verify';
const key = parseAnchorKey(readFileSync('anchor-identity.pub'));
const result = verifyChain(readFileSync('ziffer-audit-example.json'), key); // throws ChainRefusal
console.log(result.lastChainHash, result.anchoredThrough, result.unanchoredFrom);10. The MCP-assisted path
If you are writing this integration with an AI assistant, you can give it direct access to this guide and to a live decision loop:
npx @ziffer-io/mcpThat starts a local MCP server over stdio. Its tools fall into the four groups below, and there are no others.
About your integration: get_integration_guide (this document, by language), propose
and check_decision (the live loop, against whatever ZIFFER_API_URL you configure),
explain_receipt (hand it a receipt and the proposal bytes and it tells you valid or the named
clause), and sandbox_status (whether the tenant id you are sending is a sandbox, and whether one
decision is still waiting on its robot approver).
About your key: whoami (which tenant your API key is bound to, and until when the key is
accepted -- two facts and no more, because nothing an SDK call does needs your policy).
About your policy repository and the rules in it: get_policy_repo_guide (its README and
the first-hour guide, verbatim), check_policy_repo (reads a clone and reports what is left to do
in it), explain_publish_failure (reads a failed publish run's log and names the step, the
refusal and the fix), explain_policy (reads a policy tree and says, per action, whether it runs
alone, runs with somebody told, is held for a quorum, or is refused and by what clause), and
simulate_decision (grades one proposal against a draft tree and gives you the verdict and the
clause).
explain_policy and simulate_decision run ziffer decide --unsigned -- the same command your own pull request workflow runs
-- rather than reading your rules a second time in TypeScript, so a verdict you read there is the
verdict the engine grades. With --unsigned no signature is checked and no activation happens, so
they answer what your rules say and not whether a bundle would load.
About getting started and reading answers: get_started (the steps in order, from
nothing), scan and explain_scan_finding (every tool your code or your machine gives a model,
graded by the engine under a draft policy, and why each was graded that way), check_integration
(each propose call site with no verify beside it), lint_proposal (one proposal against the
wire schema, on your machine), get_decision (one decision, its receipt verified under your
trust anchor), list_decisions (section 6's list), search_docs and explain_refusal (these
guides and the support guide's refusal table), and send_feedback, which sends the text you give
it to us, to be read by a person.
It is configured by the same environment variables as the SDK, and it starts without them.
Tools that need a value return a named error saying which variable to set, rather than the
server dying during startup where an AI agent cannot see why. get_integration_guide needs nothing
at all, so an AI agent can read this document before any credential exists.
Two things it deliberately does not have. There is no approve tool and no simulated approval:
an approval minted on a developer's laptop is a fake receipt factory, which is precisely what this
product exists to prevent. sandbox_status is not one: a ZIFFER sandbox is a separate tenant
approved by a service inside our deployment, under keys your machine does not hold, so that tool
reports and approves nothing. Neither is simulate_decision: it runs the grader, which answers
what your rules say and signs nothing, and a PASSED from it is a grade rather than permission to
act. And no tool writes into your project: nothing in that server creates, edits or deletes
a file in your repository, in your policy tree, or anywhere else you keep work. Two tools write
outside it. simulate_decision writes the proposal into a temporary directory and removes it
again, because --proposal takes a path. scan writes its results into a fresh directory under
the system temporary directory and leaves them there for you to read: the draft policy folder
ziffer-policy-<date> and, beside it, ziffer-tools.json (each tool's key and resource, which
the ZIFFER snippet reads), ziffer-scan.json (the result), and, unless you pass report: false,
the report ziffer-scan-report.html and the review archive ziffer-review.tar.gz. The one case in
which those files land somewhere you choose is a confirmed scan of the installed servers with
out naming a folder: they go there, and a folder inside your project is then yours to have
named. scan starts the MCP servers your AI agent clients are configured to start only when
called a second time with confirm: true. The server serves knowledge and ZIFFER-side calls;
your AI assistant edits your code.
To say it once more, because it is the sentence most easily lost: MCP alone routes the
question; only the SDK's verify line enforces the answer. An integration written with the
MCP server's help is gated by exactly the lines of verify that ended up in your handler.
11. Checklist
- Your API key is in
ZIFFER_API_KEYand is not in your source tree. - You know the date your key expires, and it is written down somewhere that will remind you. The SDK warns for the last fourteen days, once per process and only while calls still succeed, and an expired key then answers exactly like a key that never existed.
- Your rotation is overlap-then-revoke: new key deployed and serving traffic BEFORE the old one is revoked.
- No request body sets a
tenant_idother than your key's. The key is the tenant. -
ZIFFER_TRUST_ANCHORpoints at aziffer pubkeydocument you obtained out of band, not at a signing key and not at something the API served you. -
ZIFFER_SUITE_FLOORis set to a floor you chose. - If you check audit chain downloads, you pass the
anchor-identity.pubwe handed you, neveranchor.json, and you compared its fingerprint with the one we read to you on a second channel first. Section 6. - Your handler calls
verifyand branches on it. Grep for the call; then grep for theif. Section 7, item 1. - Your execution is idempotent on your own key. Section 7, item 2.
- You keep the receipt somewhere you can produce it later.
- You have read section 7 and know the six things that stay your code's job.
- If you were given sandbox credentials, your production configuration still points at your production key and anchor. Section 7, item 6.
- You know where to write when something is refused, and what to put in the report. The support guide has the addresses, the severities, and, in its section 4, a row per refusal saying what it means and who moves next.