On this page

API reference

Everything in the dashboard, as an API.

Create receipts for anything, send end-to-end encrypted agreements, prove facts in zero knowledge, issue Proof Cards, seal documents until a deadline or an approval, collect anonymous verified responses, and verify all of it. Attest handles the wallets, the gas, and the blockchains behind every call. You send JSON. What only a person's passkey can do is listed under Dashboard-only.

Base URL

https://attest.datahubz.com/api/v1

Format

JSON in, JSON out. Times are ISO 8601, UTC.

Authentication

Create an API key in the dashboard (Build, then Developers, then New key) and send it as a bearer token. A key acts as the account that created it. Verification endpoints, and the others marked Public, need no key. To try any endpoint with your key before writing code, open the API playground.

header
Authorization: Bearer ak_live_...

Errors and limits

Errors use HTTP status codes and a consistent body. The free plan includes 10 records a month: receipts, agreements, proofs, and Proof Cards you create, and sealed calls, releases, vaults, and forms. Creating a receipt, agreement, proof, or Proof Card returns your allowance in X-Attest-Quota-* headers. Signing, reading, and verifying never count.

error body
{ "error": { "code": "quota_exceeded", "message": "Free plan limit of 10 records this month reached." } }
400 bad_requestThe request is missing or has an invalid field.
401 unauthorizedNo valid API key.
402 quota_exceededThe free plan's monthly allowance is used up.
403 / 404Not yours, private, or doesn't exist.
409Not possible in the current state (for example, not your turn to sign).

Receipts

A receipt proves that content with a given SHA-256 fingerprint existed, unchanged, at a point in time. Hash locally and send only the fingerprint: your content never has to leave your systems. Receipts are batched and anchored on a public blockchain within minutes.

POST/api/v1/receiptsAPI key Try it
Create a receipt.
sha256stringSHA-256 of the content, 64 hex characters. Preferred.
contentstringAlternatively, text to hash server-side (and discard).
kindstringOne of: document, image, video, audio, event, agent_action, code_artifact, custom. Default document.
claimstringA short human statement, e.g. “Q3 report, final”.
metadataobjectAny JSON you want recorded with it.
supersedesstringThe receipt ID this one corrects. Nothing is ever deleted.
curl
curl -X POST https://attest.datahubz.com/api/v1/receipts \
  -H "Authorization: Bearer ak_live_..." -H "Content-Type: application/json" \
  -d '{
    "kind": "agent_action",
    "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "claim": "Agent invoice-bot filed the Q3 VAT return",
    "metadata": { "agentId": "invoice-bot", "authorizedBy": "cfo@acme.com" }
  }'
response 201
{ "id": "rcpt_01JD...", "kind": "agent_action", "status": "pending", "anchor": null, ... }
GET/api/v1/receipts?kind=&limit=API key Try it
Your receipts, newest first (limit up to 200). Each includes its anchor once confirmed: chain, transaction, block, and an explorer link.
GET/api/v1/receipts/{id}API key Try it
One receipt, with its Merkle inclusion proof and anchor.

Verify (public)

Anyone can verify, with no account and no key. The same checks power the verifier at attest.datahubz.com/verify. New to Attest? Start with the introduction.

POST/api/v1/verifyPublic Try it
Send one of the following.
receiptIdstringVerify a receipt: inclusion proof, anchor, and any zero-knowledge claim.
sha256stringFind the receipt for a fingerprint, plus signed agreements (with public certificates) whose document has it.
agreementIdstringA signed agreement's signers and anchor, if its certificate is public.
proofIdstringA zero-knowledge agreement proof: validity, identities, anchor, zkVerify.
credentialobject | stringA W3C Verifiable Credential (object), or an SD-JWT VC (string): signature, issuer, revocation and suspension status, validity dates, and for Proof Cards the issuance anchor. Other issuers too: did:web and did:key, with ecdsa-sd-2023, ecdsa-rdfc-2019, eddsa-rdfc-2022, or Ed25519Signature2020 proofs.
shareIdstringA Proof Card someone shared (shr_…), checked the same way, plus the holder's passkey approval of that exact copy.
curl
curl -X POST https://attest.datahubz.com/api/v1/verify -H "Content-Type: application/json" \
  -d '{ "sha256": "'"$(shasum -a 256 contract.pdf | cut -d' ' -f1)"'" }'

Agreements

Agreements are end-to-end encrypted: you encrypt the document before sending it, so Attest only ever stores ciphertext. Each party has their own key pair, set up once in the dashboard with a passkey. Signers are emailed a signing link; when everyone has signed, the agreement is anchored on a public blockchain from its creator's wallet.

POST/api/v1/agreementsAPI key Try it
Create an agreement. The document arrives already encrypted (see the next section).
titlestringRequired.
documentSha256stringSHA-256 of the plaintext document. Required; it's what gets anchored.
documentNamestringFile name, e.g. nda.pdf.
documentobject{ ciphertext, iv, contentType, size }, AES-256-GCM, base64.
creatorWrappedDekstringThe document key wrapped to your public key. Required.
signerWrappedDeksobject{ email: wrappedKey } for signers who already have a key. Others are granted later.
signersarray[{ email, name? }], up to 20.
includeSelfbooleanAdd yourself as a signer.
selfPositionstringfirst (default) or last, when signing in order.
sequentialbooleanSign one at a time, in order.
publicCertificatebooleanDefault true. False keeps the certificate private; outsiders verify through zero-knowledge proofs.
GET/api/v1/agreementsAPI key Try it
Agreements you created, plus incoming (awaiting your signature) and signed (signed by you).
GET/api/v1/agreements/{id}API key Try it
One agreement you created or are a party to: status, signers, and anchor.

Encrypting a document

Generate a random 256-bit document key, encrypt the file with AES-GCM, and wrap the key to each party's RSA-OAEP (SHA-256) public key. Fetch public keys with POST /api/v1/keys/public. This runs anywhere WebCrypto does: Node 20+, Deno, Bun, or the browser.

POST/api/v1/keys/publicAPI key Try it
Public keys for up to 25 emails: { "emails": [...] } returns { "publicKeys": { email: spkiBase64 } }. People without a key yet are simply absent; grant them access after they set one up.
node 20+
import { readFile } from "node:fs/promises";
import { createHash, webcrypto as crypto } from "node:crypto";

const API = "https://attest.datahubz.com/api/v1";
const KEY = process.env.ATTEST_API_KEY;
const api = async (method, path, body) => {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(JSON.stringify(await res.json()));
  return res.json();
};
const b64 = (buf) => Buffer.from(buf).toString("base64");

const me = "you@company.com";
const signers = ["mary@acme.com"];
const doc = await readFile("nda.pdf");

// 1. Public keys (yours is required; signers without one are granted later)
const { publicKeys } = await api("POST", "/keys/public", { emails: [me, ...signers] });

// 2. Encrypt the document with a fresh key
const dek = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const aes = await crypto.subtle.importKey("raw", dek, "AES-GCM", false, ["encrypt"]);
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aes, doc);

// 3. Wrap the key to each party
const wrap = async (spki) => {
  const pub = await crypto.subtle.importKey("spki", Buffer.from(spki, "base64"),
    { name: "RSA-OAEP", hash: "SHA-256" }, false, ["encrypt"]);
  return b64(await crypto.subtle.encrypt({ name: "RSA-OAEP" }, pub, dek));
};
const signerWrappedDeks = {};
for (const email of signers) if (publicKeys[email]) signerWrappedDeks[email] = await wrap(publicKeys[email]);

// 4. Create it
const agreement = await api("POST", "/agreements", {
  title: "Mutual NDA, Acme & Co",
  documentName: "nda.pdf",
  documentSha256: createHash("sha256").update(doc).digest("hex"),
  document: { ciphertext: b64(ciphertext), iv: b64(iv), contentType: "application/pdf", size: doc.length },
  creatorWrappedDek: await wrap(publicKeys[me]),
  signerWrappedDeks,
  signers: signers.map((email) => ({ email })),
  includeSelf: true,
  sequential: true,
});

// 5. Email the signing links
await api("POST", `/agreements/${agreement.id}/send`);

Agreement actions

POST/api/v1/agreements/{id}/sendAPI key Try it
Email signing links to pending signers (only the current one, when signing in order). Also used to remind.
POST/api/v1/agreements/{id}/consentAPI key
Start signing as the creator (when you added yourself and it's your turn). Body: { "name": "Your Name" }. Returns the exact consent statement, a consentToken (valid 5 minutes), the WebAuthn challenge (SHA-256 of the statement), and your signing passkeys as allowCredentials.
POST/api/v1/agreements/{id}/signAPI key
Record the signature: { "consentToken", "assertion": { id, response: { authenticatorData, clientDataJSON, signature } } }, the passkey assertion over that challenge, base64url. Attest verifies it against your passkey's public key before recording it. A signature always comes from a person's passkey, so this step runs where the passkey is: the dashboard, or your own WebAuthn integration. Other parties sign through their emailed link.
GET/api/v1/agreements/{id}/grantAPI key Try it
Signers who have set up a key but can't open the document yet, with their public keys.
POST/api/v1/agreements/{id}/grantAPI key
Give them access: unwrap your document key locally, re-wrap it to each public key, and send { "grants": { email: wrappedKey } }.
GET/api/v1/agreements/{id}/documentAPI key Try it
The encrypted document and your wrapped key: ciphertext, iv, wrappedDek, and documentSha256 to check the result against.
POST/api/v1/agreements/{id}/visibilityAPI key Try it
{ "publicCertificate": true | false }. Private agreements are visible only to their parties; others verify them through zero-knowledge proofs.
POST/api/v1/agreements/{id}/voidAPI key Try it
Void an agreement that isn't completed. Body: { "reason": "..." }.

Zero-knowledge proofs

Prove a fact about a completed agreement you're a party to, revealing only that fact. Proofs are Groth16, checked against an anchored record of all agreements, and verified independently on zkVerify.

POST/api/v1/agreement-proofsAPI key Try it
Generate a proof (takes a few seconds).
agreementIdstringA completed agreement you're a party to.
circuitstringagreement_with_party (default): you and a named counterparty signed it, on a date. agreement_membership: you're a party to it, on a date.
counterpartyEmailstringRequired for agreement_with_party: another signer to reveal.
response 201
{ "id": "azk_01M3..." }
GET/api/v1/agreement-proofsAPI key Try it
Your proofs, and the completed agreements you can prove over (with possible counterparties).
GET/api/v1/agreement-proofs/{id}Public Try it
Verify a proof: the statement, validity, identity match, anchor, and zkVerify status. Share https://attest.datahubz.com/claim/{id} with anyone.

Compliance proofs

Compliance proofs run on the security-scan Groth16 circuit from Hubz VCE, a DataHubz project. Each proof is checked by Attest and verified on zkVerify, and its receipt is anchored on Horizen.

POST/api/v1/zk-claimsAPI key Try it
Prove a predicate over a security scan without revealing the scan: send the counts, get a receipt carrying a proof. Only the proof and its outcome are stored, never the counts.
predicatestringzero_critical, zero_high, zero_both, score_threshold, or full_compliance.
scanobject{ criticalCount, highCount, mediumCount, lowCount, infoCount, securityScore, scanners[], minSecurityScore? }
claimstringOptional label for the receipt.

Proof Cards

Issue credentials people carry and prove anywhere: training, supplier approvals, memberships, employment, or your own fields. Each card is a W3C Verifiable Credential 2.0, signed by your organization with a Data Integrity proof (ecdsa-sd-2023), so the holder can reveal only the fields they choose and the signature still verifies. Revocation and suspension use W3C Bitstring Status Lists. Every issuance and every status-list change is anchored on Horizen, so an issuer can't backdate a card or hide a revocation. Issuing uses one record of your allowance; status changes, reading, and verifying don't.

Training cards are issued in the 1EdTech Open Badges 3.0 format (OpenBadgeCredential), so learning platforms and badge wallets can read them. You send the same fields; Attest maps completedOn to activityEndDate, hours to creditsEarned, result to a Result, and the title to the Achievement, which is always disclosed.

POST/api/v1/credentialsAPI key Try it
Issue a card from one of your organizations. The recipient is emailed a link to accept it.
templatestringtraining, supplier, membership, employment, or custom. GET /api/v1/credential-templates lists their fields.
titlestringRequired. What the card is: the course, the approval scope, the membership, the role.
recipientobject{ email, name? }. The email delivers it; it's only put in the credential with includeEmail.
fieldsobjectThe template's fields, e.g. { completedOn: "2026-09-20", hours: 8 }. For custom, your own labels.
validUntilstringISO date. Omit for the template's default (a year for most); null for no expiry.
validFromstringISO date. Default now.
descriptionstringOptional, always disclosed.
includeEmailbooleanDefault false.
orgIdstringWhich of your organizations issues it. Default your first.
notifybooleanDefault true: email the recipient.
curl
curl -X POST https://attest.datahubz.com/api/v1/credentials \
  -H "Authorization: Bearer ak_live_..." -H "Content-Type: application/json" \
  -d '{
    "template": "training",
    "title": "Forklift operator certification",
    "recipient": { "email": "mary@acme.com", "name": "Mary Ruiz" },
    "fields": { "completedOn": "2026-09-18", "hours": 16, "result": "Passed" }
  }'
response 201
{ "id": "cred_01M3...", "credentialId": "urn:uuid:...", "state": "active",
  "issuer": { "name": "Acme Training", "did": "did:web:attest.datahubz.com:orgs:acme-training" },
  "claimUrl": "https://attest.datahubz.com/cards/claim/...", "receiptId": "rcpt_01M3...", ... }
GET/api/v1/credentials?orgId=&state=&q=&limit=API key Try it
Cards your organizations issued, newest first (limit up to 100).
GET/api/v1/credentials/{id}API key Try it
One card, with verifiableCredential: a verifiable copy disclosing every field.
POST/api/v1/credentials/{id}/statusAPI key Try it
{ "action": "suspend" | "reinstate" | "revoke", "reason"?: "...", "message"?: "..." }. Publishes a new, anchored version of your status list and emails the holder. reason stays private to you; message is included in the email. Revocation is permanent.
GET/api/v1/credential-templatesPublic Try it
The templates and their fields.

Checking a card without Attest. Everything a verifier needs is public and standard, so any software supporting these W3C specifications can check a card on its own:

/orgs/{slug}/did.jsondid:webThe issuer's DID document and P-256 public key (Multikey).
/orgs/{slug}/status/{n}/{purpose}W3CThe signed Bitstring Status List credential (revocation or suspension).
…/{purpose}/historyJSONEvery published version of that list, with its SHA-256 and anchor.
/contexts/cards/v1JSON-LDThe context defining Proof Card types and fields.

Wallets: OpenID4VCI and OpenID4VP

Every card exists in two formats: an IETF SD-JWT VC (dc+sd-jwt, ES256, issuer did:web, revocation through an IETF Token Status List) following the FIDES DIIP v5 interoperability profile, and the W3C Verifiable Credential described above. Wallet apps pick the format they read.

Proof Cards work with wallet apps through the OpenID Foundation's standards. OpenID4VCI 1.0 delivers a card to a wallet: the holder clicks “Add to a wallet app” and gets a one-time QR code and a 6-digit PIN (pre-authorized code flow). A wallet that proves its key (a jwt proof, did:key or did:jwk) receives a copy bound to that key (cnf.kid for SD-JWT, credentialSubject.id for W3C); otherwise an unbound one. The authorization code flow is supported too, with pushed authorization requests and PKCE S256. OpenID4VP 1.0 lets you ask someone to present a card: wallet apps get a request object signed by Attest's did:web (passed by reference, request_uri_method=get), with a DCQL query accepting either format and direct_post. A presentation is accepted only with holder binding: an SD-JWT key binding JWT for this request, a W3C presentation signed by the key the card is bound to, or approval with the holder's passkey in Attest.

/.well-known/openid-credential-issuerOID4VCICredential issuer metadata: one ldp_vc configuration per card type.
/.well-known/oauth-authorization-serverOAuthToken endpoint for the pre-authorized code grant.
/api/oid4vci/par · /oid4vci/authorizeOAuthPushed authorization request, then the holder approves at Attest (authorization code flow).
/api/oid4vci/token · /nonce · /credentialOID4VCIExchange the code (and PIN, or PKCE verifier); get a nonce; collect the credential.
/orgs/{slug}/statuslists/{n}IETFToken Status List for SD-JWT cards (2 bits: valid, revoked, suspended).
/vct/{type}SD-JWT VCType metadata: how wallets name and display each card type.
/.well-known/did.jsondid:webAttest's verifier identity, which signs presentation requests.
/api/oid4vp/response/{id}OID4VPdirect_post endpoint for a presentation request.

Check the card, not the screen. A wallet app's display, a screenshot, or a printout can't show that a card is still valid, and some wallet apps don't show a suspension or revocation to their user. Verify by requesting the card (below) or opening its share link: every check reads the issuer's status list at that moment. When an issuer suspends, reinstates, or revokes a card, Attest emails the holder.

Wallet apps that work with Attest cards

Apple Wallet and Google Wallet can't hold these cards.

POST/api/v1/presentation-requestsAPI key Try it
Ask someone to present a card.
purposestringRequired. Shown to the holder before they decide.
credentialTypestringOpenBadgeCredential, SupplierApprovalCredential, MembershipCredential, EmploymentCredential, or AttestCredential. Omit for any.
fieldsstring[]credentialSubject properties to reveal, e.g. ["name", "activityEndDate"].
issuerDidstringAccept only cards from this issuer.
response 201
{ "id": "vpr_01M3...", "status": "waiting",
  "walletLink": "openid4vp://?client_id=redirect_uri%3A...&dcql_query=...",
  "attestLink": "https://attest.datahubz.com/present?...", "expiresAt": "..." }
GET/api/v1/presentation-requestsAPI key Try it
Your last 50 presentation requests, newest first.
GET/api/v1/presentation-requests/{id}API key Try it
status is waiting, verified, rejected, or expired. Once answered, result has the credential's verification, the holder binding, and whether it meets your request.

Sealed submissions

Collect proposals, bids, or applications that no one can open before a deadline. Each submission is encrypted in the submitter's browser with a fresh AES-256 key; that key is wrapped to every reviewer's public key, and the wrapped keys are time-locked with drand (quicknet, the League of Entropy) to the first round published at or after the deadline. Before then, no one can open a submission: the key that unlocks the time lock is produced by a threshold of independent drand operators only at that moment. After it, only the reviewers can, since Attest never holds their private keys. The call's terms (deadline, round, reviewers) are anchored when it's created, and every submission's sealed bytes when it arrives. Replacements are allowed before the deadline, and every version stays on record.

A key id, here and in the sections below, is the SHA-256 (hex) of the person's public key as its base64 SPKI text. Get public keys with POST /api/v1/keys/public.

POST/api/v1/sealedAPI key Try it
Create a call: { "title", "description"?, "deadline": ISO, "reviewerIds"?: [...], "orgId"? }. The deadline must be 5 minutes to a year away. Reviewers are you plus members of your organization who have an encryption key (GET /api/v1/sealed/reviewers lists them with their ids). Returns { id, url, deadline, drandRound, termsReceiptId }. Uses one record.
GET/api/v1/sealedAPI key Try it
Your calls (as organizer or reviewer), and the submissions you made.
GET/api/v1/sealed/{id}Public Try it
The call's terms, its time lock (drand.chain, drand.round), the reviewers' public keys and key ids to seal to, and every submission's receipt (no names).
POST/api/v1/sealed/{id}/submissionsAPI key
Submit, sealed on your side: { ciphertext, iv, metaCiphertext, metaIv, sealedKey }, where sealedKey is a tlock (age) ciphertext of { reviewerKeyId: wrappedKey } for the call's round, up to 3 MB. Keys sealed to any other round are refused. Submitting again replaces your earlier version for the reviewers.
GET/api/v1/sealed/{id}/submissionsAPI key
Reviewers: who submitted and when; the sealed contents only after the deadline.
POST/api/v1/sealed/{id}/openedAPI key
Reviewers, after the deadline: record that the submissions were opened (shown on the public call page).

Approval release

A document that opens for chosen recipients only after k of n approvers approve it, and optionally not before a date. It's encrypted in the creator's browser with a key K = K1 xor K2. K1 is split with Shamir's secret sharing into one share per approver (any k rebuild it; fewer reveal nothing), and each share is encrypted to that approver's public key. K2 is time-locked with drand to the “not before” date, or zero without one. Approving happens in the approver's browser: their share is decrypted with their key, re-encrypted to each recipient, and the decision is signed with their passkey. Recipients rebuild K from k shares (plus drand's key for the date). Attest only ever holds ciphertext. The terms (approvers, threshold, recipients, date, document fingerprint) are anchored at creation, and every approval or decline when it's made.

POST/api/v1/releases/keysAPI key Try it
{ "emails": [...] } returns { found: [{ email, publicKey, keyId }], missing: [...] } for up to 25 people. Everyone taking part needs an Attest account with an encryption key. Because this shows whether an address has a key, each account can look up at most 60 new addresses an hour and 300 a day (repeat lookups are free); beyond that it returns 429 rate_limited.
POST/api/v1/releasesAPI key
Create a release, sealed on your side: { title, description?, ciphertext, iv, metaCiphertext, metaIv, threshold, approvers: [{ email, keyId, share }], recipients: [{ email, keyId }], notBefore?, timeKey? }. 1 to 10 approvers and recipients, up to 3 MB. timeKey is a tlock ciphertext of K2 for the drand round of notBefore (at least 5 minutes away). Approvers are emailed. Returns { id, url, termsReceiptId }. Uses one record.
GET/api/v1/releasesAPI key Try it
Releases you created, approve, or receive, with approval counts.
GET/api/v1/releases/{id}API key Try it
Its terms and each approver's decision. Recipients get the ciphertext, the time lock, and the shares re-encrypted to them only once enough approvals are in and the date has passed.
POST/api/v1/releases/{id}/cancelAPI key
The creator cancels a release no recipient has opened yet.

Approving and declining are signed with the approver's passkey, so they happen in the dashboard.

Continuity vault

Documents and notes kept sealed until the moment others need them: handovers, account access, instructions. They're released to chosen beneficiaries only if the owner stops checking in and k of n trustees confirm. Each item is encrypted in the owner's browser with its own key; those keys are wrapped with a vault key V, which is wrapped to the owner and split among the trustees with Shamir's secret sharing. Once the owner misses a check-in plus the grace period, each trustee can confirm with their passkey, re-encrypting their share to the beneficiaries in their own browser. A check-in cancels outstanding confirmations, and locks a released vault again unless a beneficiary has already received it. Changing trustees or beneficiaries re-keys the vault. The terms, every item and every confirmation are anchored.

What's guaranteed by cryptography: fewer than k trustees can't open it, and neither can Attest. What's Attest's rule: the check-in timer, which decides when trustees may confirm.

POST/api/v1/vaultsAPI key
Create a vault, keyed on your side: { title, checkInDays: 7–365, graceDays: 1–60, threshold, trustees: [{ email, keyId, share }], beneficiaries: [{ email, keyId }], ownerKey, ownerKeyId, contents: [{ ciphertext, iv, metaCiphertext, metaIv, wrappedDek }] }. At least one item in contents: a vault can't be empty. You can't be your own trustee or beneficiary. Trustees and beneficiaries are emailed. Uses one record.
GET/api/v1/vaultsAPI key Try it
Vaults you own, are a trustee of, or a beneficiary of.
GET/api/v1/vaults/{id}API key Try it
The vault as you see it. The owner gets the wrapped vault key and each item's wrapped key; a beneficiary adds ?open=1 after release to receive the items and the trustees' shares (from then on, a check-in can't lock it again).
PUT/api/v1/vaults/{id}API key
The owner changes name, timing, trustees, threshold or beneficiaries, always under a new vault key: the same fields as creating, plus items: [{ id, wrappedDek }] re-wrapping every item's key. Outstanding confirmations are cancelled, and it counts as a check-in.
DELETE/api/v1/vaults/{id}API key
The owner closes the vault; its contents are deleted.
POST/api/v1/vaults/{id}/itemsAPI key
Add an item: { ciphertext, iv, metaCiphertext, metaIv, wrappedDek }, up to 3 MB each and 20 per vault.
GET/api/v1/vaults/{id}/items/{itemId}API key
The owner's own encrypted item. DELETE removes it; the last item can't be removed.

Check-ins and trustee confirmations are signed with a passkey, so they happen in the dashboard.

Verified Forms

Anonymous responses from a known group, one per member: team pulses, committee votes, member and customer feedback. Built on Semaphore v4 (Groth16 on BN254). Each invited member joins by creating a per-form identity in their browser; only its commitment joins the group (a Merkle tree). A response carries a proof that its author is in the group, bound to the exact answers (the proof's message is sha256(canonicalJson({ form, answers }))) and to the form (its scope). The nullifier is the same for any two responses from one member, so a second response is refused, without revealing who either came from. Responses are submitted without a session. Every proof is verified by Attest and also on zkVerify.

The terms are anchored at creation; with “join first, then answer”, the group is fixed and anchored when answering opens; the full set of responses is anchored at close. Answering needs at least 3 members in the group. The evidence file contains everything needed to re-check the results: the group's commitments, and every response with its proof (depth 16).

POST/api/v1/formsAPI key
Create a form and invite people: { title, description?, questions: [{ type: single|multiple|rating|text, label, options?, required }], emails: [...], includeOrg?, card?: { template, title }, twoPhase, opensAt?, closesAt, visibility: organizer|participants|public }. Up to 30 questions; single and multiple choice take 2 to 20 options; ratings are 1 to 5. With twoPhase, joining closes at opensAt (at least 10 minutes away). With card, holders of an active Proof Card of that type from your organization are eligible, checked when they join (a revoked or suspended card can't join; a card received before joining closes can). At least 3 people. Uses one record.
GET/api/v1/formsAPI key Try it
Forms you created and forms you can answer, plus cardKinds: the card types your organization has issued, for card eligibility.
GET/api/v1/forms/{id}API key Try it
The form, its status, and counts (invited, joined, responses, verified on zkVerify). After it closes, the results, if you can see them. DELETE cancels it (the organizer, before its results are anchored).
POST/api/v1/forms/{id}/openAPI key
The organizer opens answering early (join-first forms): joining closes and the group is fixed.
POST/api/v1/forms/{id}/closeAPI key
The organizer closes the form early; the responses are anchored.
GET/api/v1/forms/{id}/publicPublic Try it
The questions and the group's commitments, for building a proof.
POST/api/v1/forms/{id}/responsesPublic
Submit { answers, proof }, where proof is a Semaphore v4 proof (depth 16) for the form's scope. No session is read; the proof is the only credential.
GET/api/v1/forms/{id}/evidenceAPI key Try it
After close, for whoever can see the results (anyone, for public results): the group, and every response with its proof and zkVerify transaction.

Joining happens in the dashboard, where the member's identity is created. What's guaranteed by cryptography: a response can't be linked to a member from the data, by the organizer or anyone checking the evidence, and nobody can answer twice or without being on the list. What remains: Attest's servers see when requests arrive; we don't link a member's visit to a response, and written answers can reveal their author by what they say.

Dashboard-only, by design

  • Keys and passkeys. Your private key is created and unlocked in your browser with a passkey, and never reaches our servers. Set it up once on the Security page.
  • Opening documents. Decryption happens where your key is. Through the API you receive ciphertext and decrypt it yourself.
  • Anything signed with a passkey. Signing agreements (other than your own WebAuthn integration, above), approving or declining a release, checking in on a vault, confirming as a trustee, and approving a Proof Card share are made by the person's passkey (Face ID, Touch ID, Windows Hello), so no API key alone can do them on anyone's behalf. Each is verifiable by anyone: POST /api/v1/verify with an agreementId returns every signer's evidence and our check of it.
  • Holding cards. Accepting a Proof Card, sharing it, and adding it to a wallet app happen in the holder's dashboard.
  • Joining a Verified Form. The member's identity for the form is created in their browser.
  • Organizations, billing, and API keys. Managed in the dashboard.