ZIFFER home

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 trustswhat rung 2 checks instead
the outcome field is what our engine decidedthe receipt's signature verifies under your configured key, every primitive, never any
this receipt is about the action you proposedthe proposal hash is recomputed from your own bytes and compared to the receipt's
the receipt is currentits validity window is checked against your clock
the receipt is one we issueda 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.

  1. 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 a tenant_id that is not the key's, the request is refused with TenantMismatch rather 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.Client and ZifferClient warn once per process on the language's standard channel (warnings.warn with the ziffer.ApiKeyExpiring category; 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.ApiKeyExpiring turns 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 one ApiKeyUnknown refusal, 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_KEY wherever 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.

  2. 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 with ed25519_pk_hex and mldsa65_pk_hex in it. Put it on the filesystem your service reads and point ZIFFER_TRUST_ANCHOR at 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.

  3. 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) and TrustAnchor.from_file(path) with no min_suite raise AnchorError naming MinSuiteRequired; in TypeScript, minSuite is a required field of the TrustAnchor interface and the compiler refuses the object. The three names it accepts are ed25519, hybrid-ed25519-mldsa65 and slhdsa128s. Set hybrid-ed25519-mldsa65 unless you have a reason: a floor naming slhdsa128s refuses 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 credential

Rung 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, unchanged

Six 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.

one proposal
{
  "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: absent

ATTEST 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_until and 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 a refusal_category and never a receipt — the shape a DENY already has.
  • A missing receipt is not an error and not a refusal. status: "decided" with no receipt means 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 with WaitTimeout, 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.

refusalwhat it meanswhat you doRule
ReceiptVersionUnsupportedWhat 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
ReceiptTooLargeThe 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
SuiteUnknownThe 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
SuiteBelowFloorThe 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
NotCanonicallyEncodableThe 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
SignatureInvalidThe 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
DecisionNotAllowThe receipt records a decision other than ALLOW.Do not run the action. Report it with the receipt.9.3-2
ReceiptNotBoundToProposalThe 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
ReceiptTimeUnreadableThe 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
ReceiptExpiredYour 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
ReceiptIssuedInFutureThe 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
ValidityWindowTooLongThe 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
NonceMalformedA 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
ReceiptNonceWrongSizeThe 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
ApprovalsUnverifiableThe 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
ApprovalDigestsUnsortedThe 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
ApprovalDigestsDuplicatedThe 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
ApprovalCountMismatchThe 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
ApprovalsMissingThe 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
ApprovalObjectMissingAn 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
ApprovalObjectMalformedAn 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
ApprovalNonceWrongSizeAn 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
ApprovalSignatureInvalidAn 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
ApprovalNotBoundToProposalAn 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
ApprovalContextMismatchAn 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
ApprovalsDisagreeOnOperatorThe 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
ApprovalQuorumSizeMismatchAn 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
ApprovalIdMismatchAn 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
ApproverAssuranceTooLowAn 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
QuorumNotMetFewer 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
OperatorApprovedOwnActionThe 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
ApproverKeyTypeMismatchAn 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
PasskeyCounterNotAdvancedA 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
ApprovalMalformedA 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
PasskeyAssertionTypeInvalidA person's approval is not a passkey sign-in assertion.Ask the approver to approve again from your approval page.WebauthnType
PasskeyChallengeMismatchA 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
PasskeyOriginMismatchA 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
PasskeyAuthenticatorDataInvalidA 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
PasskeySignatureInvalidA 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 whenclause (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 policy9.3-4PolicyBasisMovedretry 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 ALLOW and 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.md section 7 has the publisher's side — your workflow prints active when 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
}
  • waiting is the one you came for: a human has been asked and has not answered yet, and expires_at is when that request stops being answerable. It is not the same as outcome: "ATTEST", which only says a quorum was asked for — after the approval lands, or the window closes, the outcome still reads ATTEST and waiting goes false.
  • receipt is a word, not the document. Fetch the document with GET /v1/decisions/{id}, which is the one route that serves receipts.
  • outcome is absent while status is pending. An absent member is absent, never null.
  • next_cursor is null at 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 your limit costs one more call that comes back empty.
  • since defaults to 24 hours ago, is strict RFC 3339 UTC — exactly 2026-09-21T18:00:00Z, no offset, no fraction, no lowercase z — and may not reach further back than 730 days, which is the retention clause 7.7 of the Agreement commits to. limit is 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".

  1. verify returns; 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.

  2. 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, verify will not be what stops them. Make your own execution idempotent, on your own key, the way you would without us.

  3. 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 verify line enforces the answer. An AI agent that has talked to the MCP server has not thereby been gated by anything.

  4. 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.

  5. 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.

  6. 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 an ALLOW there means the path worked, never that anyone agreed. And it changes nothing about your code: your handler still performs the action, so a sandbox ALLOW handed 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, unchanged

json.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, unchanged

result.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.

transfer.ts
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/mcp

That 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_KEY and 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_id other than your key's. The key is the tenant.
  • ZIFFER_TRUST_ANCHOR points at a ziffer pubkey document you obtained out of band, not at a signing key and not at something the API served you.
  • ZIFFER_SUITE_FLOOR is set to a floor you chose.
  • If you check audit chain downloads, you pass the anchor-identity.pub we handed you, never anchor.json, and you compared its fingerprint with the one we read to you on a second channel first. Section 6.
  • Your handler calls verify and branches on it. Grep for the call; then grep for the if. 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.

On this page