Your host proves. AffixIO verifies, signs with ML-DSA-65 and anchors the digest in a Merkle audit trail. There is no server-side prove. This page documents both Node packages against the live API at api.affix-io.com.
AffixIO answers one question at a time: is this subject eligible, yes or no. The credential never leaves your infrastructure. Your host builds the witness and generates the proof, then sends the proof and its digest for verification, post-quantum attestation and Merkle anchoring.
Two Node packages cover the two ends of the latency and assurance trade-off. Both share the same configuration surface, storage layer, offline queue, data-check adapters and carrier tooling, so moving between them is a one-line change.
@affix-io/sdk
v1.1.1
Local zero-knowledge prove with bundled Noir circuits over Barretenberg UltraHonk. Use it when the proof itself must reveal nothing and you can spend seconds on generation.
Prove
UltraHonk, seconds
Deps
Noir + bb.js
Sync
Circuit verify + attest
@affix-io/sdk-light
v1.1.3
Affix Light proves with HMAC-SHA256 over the same yes/no witness layout, using Node crypto only. Use it for kiosks, phones and gates where the interaction has to feel instant.
Prove
HMAC, milliseconds
Deps
Node crypto (+ QR)
Sync
Digest attest + Merkle
Assurance
Affix Light proofs are not SNARKs. They bind a decision and witness with a shared light key, and anyone holding that key can mint one. Keep the light secret on hardware you control and pick @affix-io/sdk when the proof must stand on its own cryptography.
Both packages are built around the same principles: privacy-preserving verification, crypto-agility through ML-DSA-65 (FIPS 204), compliance-grade audit trails, and offline-first operation so a dropped link never blocks a decision.
@affix-io/sdk pulls @aztec/bb.js and @noir-lang/noir_js for proving, plus qrcode and bwip-js for carriers. @affix-io/sdk-light only needs the QR and barcode libraries, which keeps the install small enough for edge devices.
Each prove call mints a fresh request_id and proof_id, with unique credential and context identifiers so nullifiers differ per request. Never reuse a proof across two admissions.
Key prefixes include aio_, demo_ and affix_. Request a key at request access or manage them in Hub. API keys are server credentials. Never ship one to a browser; put a same-origin proxy in front and let the proxy attach the key.
These reads need no key: GET /api/health, GET /api/governance-stats, GET /v1/merkle/root, GET /v1/openapi.json and GET /.well-known/affix-mldsa65.json.
const result = await sdk.verify("simple_yesno", proved.proof, true, {
queueOnFailure: true,
});
if (result.verified && result.decision === "yes") {
admit(result.proof_digest);
}
With queueOnFailure a network failure re-queues the proof instead of throwing away the decision, which is what you want at a gate that must keep moving.
Local verify without a round trip
The full SDK exports localVerify and unpackProof for inspecting a proof offline. SDK-Light exposes verifyLocal on the instance, which recomputes the HMAC in milliseconds.
Anything proved without a live round trip lands in the queue. The SDK drains it on a timer, roughly every 5 seconds while work is pending. Manual and automatic flushes share one lock, so batches never overlap.
Each flush runs four steps: build a client Merkle batch over pending digests up to 50,000 leaves, run circuit verify for each proof across parallel workers, require ML-DSA-65 attestation before marking a proof synced, then anchor the Merkle leaf on the same verify.
flush.ts
const flush = await sdk.flushOfflineQueue({ maxItems: 5000 });
console.log(flush.attested, flush.synced, flush.failed);
console.log(flush.merkle_api_root, flush.merkle_leaves_per_sec);
// Timer control for long-lived hostsconst status = await sdk.autoFlushStatus();
// { active, interval_ms, pending, last_at, last_error, last_result }
sdk.startAutoFlush(2000);
sdk.stopAutoFlush();
sdk.dispose(); // on shutdown
Production admits
digestsOnly: true skips zero-knowledge verify and only attests and Merkle-anchors the digests. It is useful for backfilling an audit trail, but it is not a production admit path because AffixIO never checks the proof itself.
To register digests as leaves without any circuit verify, call anchorPendingLeaves(). It returns counts for attempted, anchored and attested leaves plus the resulting root and throughput.
Inspecting the queue
queue.ts
await sdk.listStoredProofs(); // StoredProof[]await sdk.listPendingSync(); // QueuedSyncJob[]await sdk.isOnline();
// Push a proof produced elsewhere into the queueawait sdk.queueProofForAffix({
proof_id: "afx_gate_8271",
circuit_id: "simple_yesno",
proof: carrierProof,
proof_digest: digest,
decision: "yes",
});
State defaults to JSON files under .affix/ for the full SDK and .affix-light/ for SDK-Light: proofs.json, offline-queue.json, licence.json and code-uses.json. Any backend with async get and set can replace it, which matters as soon as you run more than one process.
Keys written to a shared store are proofs, offline-queue, code-uses and licence, exported as STORAGE_KEYS. Per-domain overrides let you send the queue somewhere different from proof storage.
The client tree is AffixIO-compatible sha256-sorted-pairs with a hard ceiling of 50,000 leaves, exported as MAX_MERKLE_LEAVES. Build the batch from digests and leave the proof bytes out when the batch is large.
merkle.ts
import { verifyMerkleProof, MAX_MERKLE_LEAVES } from"@affix-io/sdk";
const batch = await sdk.buildMerkleBatch({ includeProofBytes: false });
console.log(batch.root, batch.leaf_count); // leaf_count <= 50000// Independent inclusion check against a published rootconst ok = verifyMerkleProof(leafHash, steps, batch.root);
Other exported helpers: buildMerkleTree, buildProofMerkleBatch, verifyBatchInclusions, merkleAuditItemsFromBatch and normalizeMerkleAuditLeaf. The public root is readable without a key at GET /v1/merkle/root, so a third party can audit inclusion without an account.
AffixIO never queries your databases. Your host looks up the record, gets back exact field strings, and proves from those strings. The decision is yes only when the claim string equals the required string, byte for byte.
Adapters ship for JSON, Mongo exports, key-value dumps, Redis exports, CSV, pipe and other delimited text, fixed-width records, dBase, XML, LDIF and INI: JsonDocumentStore, MongoDocumentStore, KeyValueStore, RedisExportStore, CsvTableStore, PipeDelimitedStore, DelimitedTableStore, FixedWidthStore, DbaseStore, XmlDocumentStore, LdifStore and IniSectionStore. Open any of them with openDataStore({ kind, path }).
checkAndProve collapses lookup and prove into one call. proveFromCheckAndVerify adds the AffixIO round trip.
Codes carry the proof, not the person. The scannable payload is the raw AFX.ZK1. carrier with no personal fields, readable by ordinary scanners. Presentment links only appear when you set presentmentBase to your own host. There is no AffixIO default link target.
Attest at AffixIO if reachable, otherwise admit locally and queue.
save.sidecar
Writes the full proof beside the code. The carrier itself stays compact.
Lower level helpers are exported too: packCarrier, unpackCarrier, isZkCarrier, renderQrSvg, renderBarcodeSvg and buildPresentmentLink. generateCodeFromCheck goes from a store lookup straight to a printable code.
The SDK heartbeats your API key on a randomised schedule between roughly 36 and 60 hours. If the licence cannot be confirmed and brickWithoutLicence is left at its default, the SDK fails closed rather than quietly continuing.
@affix-io/sdk-light is the edge package for ordinary devices: phones, kiosks, laptops and host apps. It does not load UltraHonk, Noir or bb.js. Affix Light prove is HMAC-SHA256 over the same yes/no witness layout, so the surrounding code is identical.
@affix-io/sdk
@affix-io/sdk-light
Local prove
UltraHonk, seconds
Affix Light HMAC, milliseconds
Dependencies
Noir and bb.js
Node crypto, plus QR libraries
AffixIO sync
Circuit verify and attest
Digest attest and Merkle by default
State directory
.affix/
.affix-light/
Use when
Strongest local zero-knowledge
Instant UX on any device
The proof scheme is affix-light-v1 with algorithm HMAC-SHA256, both exported as LIGHT_SCHEME and LIGHT_ALG. The signing key defaults to a value derived from your API key via deriveLightSecretFromApiKey, or set lightProveSecret for a fleet-wide key.
verify on SDK-Light does not call UltraHonk circuit verify, because light proofs are not SNARKs. It checks the HMAC locally, then asks AffixIO to attest and anchor the digest.
Naming
AffixSDK is exported from @affix-io/sdk-light as a deprecated alias of AffixLightSDK for drop-in familiarity. Prefer the explicit name so the assurance level is obvious at the call site.
Every light proof is ML-DSA-signed individually at AffixIO. The scheduler adapts to your ingest rate so a quiet gate stays responsive and a busy one stays efficient.
Ingest rate
Mode
Schedule
Up to 20 proofs per second
individual
Flush every second, up to 50 per tick, plus flush on prove.
Attestations are signed with ML-DSA-65, the FIPS 204 post-quantum signature scheme. The SDKs attach one to every synced proof when requestAttestation is on, which is the default.
The verification key is published at GET /.well-known/affix-mldsa65.json. Verify through POST /api/attest/verify, or offline with any FIPS 204 capable library. Most builds of common crypto libraries do not expose ML-DSA-65 yet, so the API call is usually the practical route.
Register an HTTPS receiver and AffixIO pushes signed events rather than making you poll. Manage endpoints in Hub. The signing secret is shown once at creation.
Each delivery carries X-Affix-Event, X-Affix-Timestamp, X-Affix-Delivery-Id and X-Affix-Signature. The signature is hmac-sha256=<hex> over {unix_timestamp}.{raw_body}.
Reject anything outside a 300 second window to block replays. Failed deliveries retry up to three times. Rotate a leaked secret with POST /api/webhooks/{id}/rotate-secret.
The export is backed by GET /v1/verify/receipts. Combined with the public Merkle root, it gives an auditor a decision trail they can verify without holding any personal data.
The default rate limit is 10 requests per second per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and, on a 429, Retry-After.
Send an Idempotency-Key on prove and verify. A replay within 24 hours returns the original 2xx body with Idempotency-Replayed: true, so a retry after a timeout cannot double-spend a digest.
Status
Meaning
Handling
401, 403
Missing or rejected key
Check the header and key prefix.
409
Digest already spent
Treat as a replay, do not admit twice.
429
Rate limited
Back off using Retry-After.
5xx
Upstream failure
Verify with queueOnFailure so the proof survives.
LicenceError is thrown when the licence heartbeat fails and brickWithoutLicence is on. Catch it at startup and alert, rather than letting a gate fail closed without warning.
What is the difference between @affix-io/sdk and @affix-io/sdk-light?
The full SDK proves with UltraHonk over Noir circuits via Barretenberg, taking seconds and producing genuine zero-knowledge proofs. SDK-Light proves with HMAC-SHA256 in milliseconds using Node crypto only. Light proofs are not SNARKs. Both sync to AffixIO for ML-DSA-65 attestation and Merkle audit.
Does AffixIO ever generate proofs on the server?
No. Both Node packages prove on your host. AffixIO verifies, signs the payload with ML-DSA-65 and anchors the digest. There is no server-side prove.
How does the offline queue and auto-flush work?
Proofs made without a live round trip are queued. The full SDK flushes about every 5 seconds: a client Merkle batch of up to 50,000 digests, circuit verify per proof across parallel workers, ML-DSA-65 attestation before a proof counts as synced, then the Merkle leaf anchor. SDK-Light flushes every second, individually up to 20 proofs per second and in batches of up to 5000 above that.
Can I replace the default JSON file storage?
Yes. Pass any object with async get and set as documentStore to use Redis, SQL, memory or a custom backend. The keys are proofs, offline-queue, code-uses and licence.
Is a proof carried in a QR code personally identifying?
No. Codes encode the raw AFX.ZK1 carrier with no personal fields. Presentment links only use a base URL you configure through presentmentBase.
How do I authenticate against api.affix-io.com?
Send Authorization: Bearer <key> or X-API-Key: <key>. The SDKs read AFFIX_API_KEY from the environment. Request a key at request access or manage keys in Hub.