ZIFFER home

Integrating the SDK

Who this is for: the engineer who owns the code that runs your agent's tool calls.

Who this is for: the engineer who owns the code that runs your 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 6 before you plan around any of this. It is placed before the code on purpose. The positive claims here are narrow, the scope that is missing is wide, and a reader who takes "the SDK verifies the receipt" to mean "the SDK stops the action" will have taken the opposite of what ships. 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 6 says why that boundary is where it is.

Rung 3 is a separate Executor process and is described in the Running the Executor guide. It is not the entry point and you should not start there.

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.

    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. The last of those is declared, not implemented, so a floor naming it refuses every receipt today rather than accepting a lattice signature in its place. Until 2026-09-05 the Python SDK filled an absent floor from the reference bundle's own field default while this paragraph said there was none; the paragraph was the half that was right.

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(decision.id)         #  3  wait for a verdict
    if decision.outcome != "ALLOW":             #  4  refused? stop.
        raise Refused(decision.clause)
    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 9.3-3 refusal, 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}.

wait returns at the decision; the receipt is a further poll. wait stops when status reaches decided, and on this path status is already decided in the answer to your POST. So it returns at once, carrying ATTEST and no receipt. Poll GET /v1/decisions/{id} yourself until receipt is present, then run line 5. There is no helper for that in the SDK today, and wait is not it. Watch the receipt, not the outcome: outcome stays ATTEST on that decision, and the receipt appearing is the change.

Measured rather than described, and measured on a laptop: on 2026-09-03 a run of a rehearsal drove the twelve services in a local docker compose stack on one machine, from a real sandbox tenant's deployed configuration tree, and got decided/ATTEST/no receipt from the POST with the receipt attached to the next GET within a second, recorded in a dated transcript we keep. A deployed control plane is still untried. This sentence said "against a live sandbox deployment" until 2026-09-05, which was a second and incompatible description of one run. It is a fact about that stack on that day; your quorum is formed by whoever your bundle enrols, and people are slower than robots.

On a deployment with no notification account, the quorum path does not complete the way this section describes, and a sandbox is such a deployment today. The Running a sandbox tenant guide states exactly what happens and what the receipt you can read does and does not prove.

5. What verify refuses, and what a refusal means

Every refusal carries a clause id. That id is the machine-readable half; the message beside it is for a human and is not part of the contract. Both SDKs and the Rust engine spell every id in the table below identically, and that is asserted rather than asserted-about: one shared corpus of vectors is run by the Python test suite and by the TypeScript one, over the same inputs. Where an implementation of this checklist still differs is stated under the table rather than left for you to discover.

the receipt is refused whenclause
it is not a version-3 receiptAB-0
its signed body is over the size capAB-6
its suite is not one this build knows, or it declares noneCR-1
your ZIFFER_SUITE_FLOOR is not a suite this build knowsCR-1
its suite does not contain every primitive of your ZIFFER_SUITE_FLOORCR-4
any signature primitive fails, the composition being conjunctive and never "any"9.3-1
its decision is not one this domain defines9.3-2
the bytes you passed are not UTF-8 JSON at allAT-8a
it is not bound to the proposal you passed9.3-3
your clock says it has expired, or its window is implausibly long9.3-5 / L-14
its nonce is not a well-formed 128-bit valueWE-4 / L-17

Two places where an id is not spelled identically everywhere. Neither changes a verdict. Both are recorded in the corpus above, pinned from both sides so they cannot move without a test going red, and both are facts about engine pin fed43d1 on 2026-09-09 rather than for ever.

  • An unknown suite name. The SDKs and the Rust engine say CR-1. The ACP reference implementation (the Python acp_executor.py the specification ships) says CR-4, because its floor test answers "no" for a name it does not recognise instead of refusing it as unrecognised. CR-4 asks whether one suite contains another's primitives, and an unregistered name names no primitives at all, so the SDKs follow CR-1: "an unregistered or unknown suite MUST fail closed". Closing the gap is a change in four places in the engine, and it is open.
  • A receipt wrong in more than one way. 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 CR-4 from the Python SDK and 9.3-1 from the TypeScript one (the Rust engine agrees with TypeScript). Both refuse; only the name differs.

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

The one refusal in this section that is worth retrying, and it is not verify's

the decision is refused whenclausewhat you do
the receipt's policy basis is not the bundle the Executor holds — the window right after you publish policy9.3-4retry once, after the activation window. This is not a policy refusal

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

6. What this does not give you yet

Read this section twice. Everything above is true and none of it adds up to "the SDK stops your 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 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. No replay protection in-process. 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 9) lets a coding agent 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 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. There is no revocation channel and no expiry on the anchor itself. Rotation today is: we tell you, 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 (clause 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 does and does not guarantee; the integration itself is the same in both, which is the point of having one.

7. Python

Install ziffer, published on PyPI at version 0.1.0, and read three environment variables. wait polls GET /v1/decisions/{id} until the status leaves pending 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(client.propose(proposal).decision_id, timeout=30.0)
    if decision.outcome != "ALLOW":
        raise PermissionError(f"ziffer refused: {decision.clause}")

    try:
        verify(decision.receipt, json.dumps(proposal).encode(), anchor)
    except RefusedError as refusal:
        # refusal.name is the clause id -- log it, do not retry it.
        raise PermissionError(f"receipt refused: {refusal.name}") 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.

8. TypeScript

All four npm packages are published, at 0.1.0 (2026-09-14): @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. The Python package is on PyPI at the same version: pip install ziffer.

@ziffer-io/client re-exports the verifier, so there is one verifyReceipt in your dependency tree rather than two implementations that can disagree. 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, verifyReceipt, Refusal, type TrustAnchor } 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'));
const anchor: TrustAnchor = await loadAnchor(env('ZIFFER_TRUST_ANCHOR'));

async function transfer(amount: number, toAccount: string): Promise<void> {
  const proposal = {
    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.wait(submitted.decision_id, { timeoutMs: 30_000 });
  if (decision.outcome !== 'ALLOW') {
    throw new Error(`ziffer refused: ${decision.clause}`);
  }

  try {
    verifyReceipt(decision.receipt, canon(proposal), anchor);
  } catch (error) {
    if (error instanceof Refusal) {
      // error.clause is the clause id -- log it, do not retry it.
      throw new Error(`receipt refused: ${error.clause}`);
    }
    throw error;
  }

  await bank.transfer(amount, toAccount);          // your line, unchanged
}

process.env is string | undefined, so the three values go through env. The client's constructor takes strings; passing the environment straight in is a type error under the settings this repository builds with, 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 under AT-8a, which is a bug in your call rather than a verdict on the receipt.


9. The MCP-assisted path

If you are writing this integration with a coding agent, you can give the agent direct access to this guide and to a live decision loop:

npx @ziffer-io/mcp

That starts a local MCP server over stdio with five tools, and no others: 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).

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 agent cannot see why. get_integration_guide needs nothing at all, so an 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. And there are no file-writing tools: the server serves knowledge and ZIFFER-side calls; your coding agent 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.


10. 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.
  • Your handler calls verify and branches on it. Grep for the call; then grep for the if. Section 6, item 1.
  • Your execution is idempotent on your own key. Section 6, item 2.
  • You keep the receipt somewhere you can produce it later.
  • You have read section 6 and know which six things this does not do.
  • If you were given sandbox credentials, your production configuration still points at your production key and anchor. Section 6, 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 a row per refusal in section 5 above saying what it means and who moves next.

Synced from ziffer docs/onboarding/sdk.md at 32bc4db; edit the source, never this page.

On this page