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 Kept with the person.
Base URL
https://attest.datahubz.com/api/v1Format
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.
Authorization: Bearer ak_live_...Errors and limits
Errors use HTTP status codes and a consistent body. On the free plan, 2 documents sent for signature, in total; for the other tools, 10 records a month: receipts, compliance proofs, Proof Cards, and certificates you create, and sealed calls, releases, vaults, and forms. Each agreement a free account creates counts against both: it uses one of the 2 documents and one of that month's records. Creating a receipt, agreement, compliance proof, or Proof Card returns your allowance in X-Attest-Quota-* headers. Signing, agreement proofs (Share proof), reading, and verifying never count. Each API key can make up to 300 requests a minute; past that, requests get 429 rate_limited with a Retry-After header. Requests with an invalid key count too.
{ "error": { "code": "quota_exceeded", "message": "Free plan limit of 10 records this month reached." } }| Parameter | Type | Description |
|---|---|---|
400 bad_request | The request is missing or has an invalid field. | |
401 unauthorized | No valid API key. | |
402 quota_exceeded | The free plan's monthly allowance is used up. | |
402 signing_allowance_exceeded | Creating an agreement: the free plan's 2 documents sent for signature, in total, are used. | |
403 / 404 | Not yours, private, or doesn't exist. | |
409 | Not possible in the current state (for example, not your turn to sign). | |
429 rate_limited | Too many requests: wait for Retry-After seconds, then retry. | |
503 unavailable | A dependency isn't ready yet (for example, no key directory root published). Retry shortly. |
Pay per record (x402)
When your allowance is used up, an AI agent or script can pay for one more record in the request itself, with x402: no card, no plan change. Creating a receipt, compliance proof, Proof Card or certificate then answers 402 quota_exceeded with a PAYMENT-REQUIRED header: US$0.01 in USDC on Base, paid to Attest. Sign the payment with any x402 client and send the same request again with a PAYMENT-SIGNATURE header (or X-PAYMENT, x402 v1). Coinbase's x402 facilitator verifies and settles it, the record is created, and the response carries the settlement in PAYMENT-RESPONSE.
Your API key is still required: a payment buys a record for your account, never an anonymous one. Each settled payment adds one credit, once; if the request fails after the payment settles, the credit stays on your account for the next record. A payment for less, or to another address, is refused with the 402 and the terms again.
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.
| Parameter | Type | Description |
|---|---|---|
sha256 | string | SHA-256 of the content, 64 hex characters. Preferred. |
content | string | Alternatively, text to hash server-side (and discard). |
kind | string | One of: document, image, video, audio, event, agent_action, code_artifact, custom. Default document. |
claim | string | A short human statement, e.g. “Q3 report, final”. |
metadata | object | Any JSON you want recorded with it. |
supersedes | string | The receipt ID this one corrects. Nothing is ever deleted. |
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" }
}'{ "id": "rcpt_01JD...", "kind": "agent_action", "status": "pending", "anchor": null, ... }limit up to 200). Each includes its anchor once confirmed: chain, transaction, block, and an explorer link.Verify (public)
Anyone can verify, with no account and no key, up to 300 requests per 10 minutes from one IP address. The same checks power the verifier at attest.datahubz.com/verify. New to Attest? Start with the introduction.
| Parameter | Type | Description |
|---|---|---|
receiptId | string | Verify a receipt: inclusion proof, anchor, and any zero-knowledge claim. |
sha256 | string | Find the receipt for a fingerprint, plus signed agreements (with public certificates) whose document has it. |
agreementId | string | A signed agreement's signers and anchor, if its certificate is public. |
proofId | string | A zero-knowledge agreement proof: validity, identities, anchor, zkVerify. |
credential | object | string | A 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. |
shareId | string | A Proof Card someone shared (shr_…), checked the same way, plus the holder's passkey approval of that exact copy. |
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 it as ciphertext, and the document key you wrap to each party's key opens it, so only you and your signers can ever open an agreement created this way. (In the dashboard, a sender can instead choose Ready now for a signer new to Attest, which keeps a sealed copy of the document's key with our signing service until the signer first opens it, then deletes that copy.) Each party has their own key pair, set up once with a passkey: you set yours up in the dashboard, and each signer sets theirs up from the signing link they're emailed. When everyone has signed, the agreement is anchored on a public blockchain from its creator's wallet.
documentCheck (yours to read) records what Attest's check found: { "clean": false, "codes": ["fonts"] } or null until known. It never holds anything up.| Parameter | Type | Description |
|---|---|---|
title | string | Required; it goes on the certificate, in the Latin, Greek, Cyrillic or Vietnamese alphabet. |
documentSha256 | string | SHA-256 of the plaintext document. Required; it's what gets anchored. |
documentName | string | File name, e.g. nda.pdf. |
document | object | { ciphertext, iv, contentType, size }, AES-256-GCM, base64. Any PDF: none is refused. |
creatorWrappedDek | string | The document key wrapped to your public key. Required. |
signerWrappedDeks | object | { email: wrappedKey } for signers who already have a key. Others are granted later. |
signers | array | [{ email, name? }], up to 20. A name goes on the signed copy and the certificate, in the Latin, Greek, Cyrillic or Vietnamese alphabet. |
includeSelf | boolean | Add yourself as a signer. |
selfPosition | string | first (default) or last, when signing in order. |
sequential | boolean | Sign one at a time, in order. |
publicCertificate | boolean | Default true. False keeps the certificate private; outsiders verify through zero-knowledge proofs. |
fields | array | Optional, up to 200: [{ type, page, x, y, w, h, signerEmail, required?, label? }]. type is signature, initials, date, name or text; page counts from 0; x, y, w and h are fractions of the page from its top-left corner. Fractions of the page as a PDF reader shows it (its CropBox, turned by its /Rotate). A signature field smaller than its whole Signed with Attest stamp needs (the label, the verification code, the signing date and a QR code) is enlarged, to the right and upward so its bottom edge stays where you put it, and moved left or down if it would leave the page: to 131 by 36 points when pageSizes declares its page's size, or else to 32% of the page's width by 7.4% of its height, which holds the whole stamp on pages A5 or larger. Other fields are kept as sent, and everything drawn for a field stays inside it. |
pageSizes | array | Optional: [{ width, height }], each page's size in points as a PDF reader shows it (turned by its /Rotate), from the first page, covering every page a field is on; each side from 3 to 14,400, at most 1,000 pages. It only decides how much a signature field is enlarged, and isn't stored: the signed copy is drawn from the document itself. Send it with fields placed for a known page size; the dashboard always does. |
Refused with 400, and nothing created, with a message that says why: bad_request for a body that isn't JSON, a missing title or document fingerprint, a title or a signer's name with characters the signed copy can't print yet (the message names them), no valid signer email, more than 200 fields, a field that isn't an object, has an unknown type, a page outside 0 to 999, a position or size that isn't a fraction from 0 to 1, doesn't fit on its page or belongs to someone who isn't a signer, page sizes that aren't a list of 1 to 1,000 { width, height } from 3 to 14,400 points or leave out a page a field is on, or an invalid expiresAt or remindEveryDays; document_required, document_too_large, key_required (a confidential agreement before your own key is set up) and too_many_signers (more than 20).
incoming (awaiting your signature) and signed (signed by you).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, and check each one against the public key directory before wrapping anything to it (step 2 below): that check is what makes sure only the people you chose can open the document, even if Attest's servers were compromised. This runs anywhere WebCrypto does: Node 20+, Deno, Bun, or the browser.
{ "emails": [...] } returns { "publicKeys": { email: spkiBase64 } }. People without a key yet are simply absent; grant them access after they set one up./api/v1/keys/directoryAPI keyAttestKeyDirectory contract on Base mainnet. Send { "emailHashes": [sha256(lowercase email), ...] } (up to 50); you get the latest published root (seq, root), the contract address, and for each person their listed key id (keyFp, the SHA-256 of the base64 SPKI text) with a proof. Only encrypt to a key if: its key id equals keyFp; the proof recomputes root (leaf = SHA-256(0x00 ‖ emailHash ‖ keyFp), node = SHA-256(0x01 ‖ left ‖ right), depth 256, path by the bits of emailHash, most significant first, bitmap marking the depths with a listed sibling); and rootAt(seq) on the contract, read from Base yourself, returns the same root. A key set up moments ago may not be published yet: POST /api/v1/keys/directory/refresh publishes it, then ask again. The dashboard does all of this before every share, and each person's browser checks their own entry in every root, so a key substituted by anyone, Attest included, is refused or found.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. Check every key against the public key directory on Base before using it
const DIRECTORY = "0xb215B46EdEEc44Eab4FFf2e7565bB3aCB0aC92a6"; // AttestKeyDirectory, Base mainnet
const BASE_RPC = "https://mainnet.base.org";
const sha = (...parts) => createHash("sha256").update(Buffer.concat(parts)).digest();
const hex = (s) => Buffer.from(s, "hex");
const emailHash = (e) => createHash("sha256").update(e.trim().toLowerCase()).digest("hex");
const bitAt = (b, d) => (b[d >> 3] >> (7 - (d & 7))) & 1;
const empty = [Buffer.alloc(32)];
for (let h = 1; h <= 256; h++) empty.push(sha(Buffer.from([1]), empty[h - 1], empty[h - 1]));
const proofRoot = (key, value, { bitmap, siblings }) => {
const k = hex(key), bm = hex(bitmap);
let node = sha(Buffer.from([0]), k, hex(value)), s = siblings.length - 1;
for (let d = 255; d >= 0; d--) {
const sib = bitAt(bm, d) ? hex(siblings[s--]) : empty[255 - d];
node = bitAt(k, d) ? sha(Buffer.from([1]), sib, node) : sha(Buffer.from([1]), node, sib);
}
return s === -1 ? node.toString("hex") : null;
};
const withKeys = [me, ...signers].filter((e) => publicKeys[e]);
const dir = await api("POST", "/keys/directory", { emailHashes: withKeys.map(emailHash) });
const onChain = await (await fetch(BASE_RPC, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [
{ to: DIRECTORY, data: "0xca2869a0" + dir.seq.toString(16).padStart(64, "0") }, "latest"] }),
})).json(); // rootAt(seq), read from Base, not from Attest
if (onChain.result?.slice(2, 66) !== dir.root) throw new Error("directory root isn't the one on Base");
for (const email of withKeys) {
const r = dir.results.find((x) => x.emailHash === emailHash(email));
const keyFp = createHash("sha256").update(publicKeys[email]).digest("hex");
// A key set up moments ago: POST /keys/directory/refresh, then check again.
if (r?.keyFp !== keyFp || proofRoot(r.emailHash, keyFp, r.proof) !== dir.root) {
throw new Error(`${email}'s key isn't the published one: don't encrypt to it`);
}
}
// 3. 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);
// 4. 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]);
// 5. 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,
});
// 6. Email the signing links
await api("POST", `/agreements/${agreement.id}/send`);Agreement actions
/api/v1/agreements/{id}/consentAPI key{ "name": "Your Name" }, the name you sign with; it can be left out when your signer entry already has one. Without a name, or with one that isn't a name or has characters the signed copy can't print yet, it's refused with 400 bad_request and a message that says why (your email address never stands in for it). Returns the exact consent statement, a consentToken (valid 5 minutes), the WebAuthn challenge (SHA-256 of the statement), and your signing passkeys as allowCredentials./api/v1/agreements/{id}/signAPI key{ "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./api/v1/agreements/{id}/grantAPI key{ "grants": { email: wrappedKey } }. The list of waiting signers comes from Attest's server, so show your user every address first and wrap only to the ones they confirm, as the dashboard does: Attest stores what you send and never chooses whom to unlock for. The answer says who was unlocked (grantedTo) and emailed (emailed); a completed, withdrawn, declined or expired agreement is refused (409). For an agreement created in Attest rather than through the API, an address that isn't on the signer list sealed when it was sent is refused (403 passkey_required): its sender unlocks it in Attest, where their passkey confirms it.ciphertext, iv, wrappedDek, and documentSha256 to check the result against.{ "publicCertificate": true | false }. Private agreements are visible only to their parties; others verify them through zero-knowledge proofs.{ "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 the anchored root of the agreement's tree (65,536 agreements each), and verified independently on zkVerify.
GET shows the anchor once it lands. Proofs come with the agreement on every plan: they don't use up records, up to 10 per party per agreement (each can be checked any number of times).| Parameter | Type | Description |
|---|---|---|
agreementId | string | A completed agreement you're a party to. |
circuit | string | agreement_with_party (default): you and a named counterparty signed it, on a date. agreement_membership: you're a party to it, on a date. |
counterpartyEmail | string | Required for agreement_with_party: another signer to reveal. |
{ "id": "azk_01M3..." }?agreementId= narrows both to one agreement.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, in its second version: the proof reveals only whether the claim holds, bound to a salted commitment to the scan. Each proof is checked by Attest and verified on zkVerify, and its receipt is anchored on Base; the receipt's fingerprint is the hash of the commitment, so it reveals nothing about the scan either.
zkClaim.outcomes.valid, the scanCommitment, and opening.salt, returned once: with the counts and scanners, it opens the commitment to an auditor.| Parameter | Type | Description |
|---|---|---|
predicate | string | zero_critical, zero_high, zero_both, score_threshold, or full_compliance. |
scan | object | { criticalCount, highCount, mediumCount, lowCount, infoCount, securityScore, scanners[], minSecurityScore? } |
claim | string | Optional 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 Base, 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.
| Parameter | Type | Description |
|---|---|---|
template | string | training, supplier, membership, employment, or custom. GET /api/v1/credential-templates lists their fields. |
title | string | Required. What the card is: the course, the approval scope, the membership, the role. |
recipient | object | { email, name? }. The email delivers it; it's only put in the credential with includeEmail. |
fields | object | The template's fields, e.g. { completedOn: "2026-09-20", hours: 8 }. For custom, your own labels. |
validUntil | string | ISO date. Omit for the template's default (a year for most); null for no expiry. |
validFrom | string | ISO date. Default now. |
description | string | Optional, always disclosed. |
includeEmail | boolean | Default false. |
orgId | string | Which of your organizations issues it. Default your first. |
notify | boolean | Default true: email the recipient. |
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" }
}'{ "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...", ... }limit up to 100).verifiableCredential: a verifiable copy disclosing every field.{ "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.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:
| Parameter | Type | Description |
|---|---|---|
/orgs/{slug}/did.json | did:web | The issuer's DID document and P-256 public key (Multikey). |
/orgs/{slug}/status/{n}/{purpose} | W3C | The signed Bitstring Status List credential (revocation or suspension). |
…/{purpose}/history | JSON | Every published version of that list, with its SHA-256 and anchor. |
/contexts/cards/v1 | JSON-LD | The 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.
| Parameter | Type | Description |
|---|---|---|
/.well-known/openid-credential-issuer | OID4VCI | Credential issuer metadata: one ldp_vc configuration per card type. |
/.well-known/oauth-authorization-server/oid4vci | OAuth | Wallet authorization server metadata (issuer /oid4vci): pushed requests, authorization and the token endpoint for the pre-authorized code grant. |
/api/oid4vci/par · /oid4vci/authorize | OAuth | Pushed authorization request, then the holder approves at Attest (authorization code flow). |
/api/oid4vci/token · /nonce · /credential | OID4VCI | Exchange the code (and PIN, or PKCE verifier); get a nonce; collect the credential. |
/orgs/{slug}/statuslists/{n} | IETF | Token Status List for SD-JWT cards (2 bits: valid, revoked, suspended). |
/vct/{type} | SD-JWT VC | Type metadata: how wallets name and display each card type. |
/.well-known/did.json | did:web | Attest's verifier identity, which signs presentation requests. |
/api/oid4vp/response/{id} | OID4VP | direct_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.
| Parameter | Type | Description |
|---|---|---|
purpose | string | Required. Shown to the holder before they decide. |
credentialType | string | OpenBadgeCredential, SupplierApprovalCredential, MembershipCredential, EmploymentCredential, or AttestCredential. Omit for any. |
fields | string[] | credentialSubject properties to reveal, e.g. ["name", "activityEndDate"]. |
issuerDid | string | Accept only cards from this issuer. |
{ "id": "vpr_01M3...", "status": "waiting",
"walletLink": "openid4vp://?client_id=redirect_uri%3A...&dcql_query=...",
"attestLink": "https://attest.datahubz.com/present?...", "expiresAt": "..." }status is waiting, verified, rejected, or expired. Once answered, result has the credential's verification, the holder binding, and whether it meets your request.Certificates
An organization attests statements about a subject, as of a point in time: a security assessment passed, an audit completed, a standard met. Each person named as a signer reviews it and signs its exact content with their passkey; then the organization signs it as a W3C Verifiable Credential (vc+jwt, ES256, the key in its DID document), Attest renders the PDF, and both are anchored on Base from the organization's own wallet. Certificates are public: anyone can open https://attest.datahubz.com/certificates/{id}, run every check in their browser, or find it by dropping its PDF (or a document it lists) on the verifier.
/api/v1/certificatesAPI keystatus: "awaiting_signatures" and a signUrl; each signer is emailed a link. The last signature issues it. Uses one record.| Parameter | Type | Description |
|---|---|---|
orgId | string | The issuing organization, by id or slug (you must be an owner). |
title, subjectName, statement | string | What is certified, about whom, in one headline statement. |
asOf | ISO 8601 | The point in time the statements are about (not in the future). |
sections | array | [{ heading, body?, items?: [{ label, detail?, ref?, result? }] }]: the full statements and checks, up to 60 sections. |
signers | array | [{ email, name, role? }]: 1 to 10 people who sign with their Attest passkey. |
language | string | en (default) or es: the page and PDF language. |
subjectDomain, validUntil, reference | optional | The subject's domain, an expiry, and your own id for it. |
attachments | array | [{ name, sha256 }]: documents the certificate covers; their hash finds it. |
supersedes | string | An issued certificate this one replaces. |
searchable | boolean | Default false. True lists its public page in search results (Google, Bing, AI assistants) while it is issued, unrevoked, not replaced and not expired. |
findingsClosure | object | { items: [{ id, severity: info | low | medium | high | critical, closed: true }] }: up to 128 findings, all closed. Adds a zero-knowledge proof that every one is closed (below). |
Findings closure. For an assessment certificate, send the findings with findingsClosure. Attest proves in zero knowledge (Groth16, findings_closure.circom) that every finding in the set is closed, without revealing how many there were, what they were or their severities. The findings are committed as a salted Poseidon hash chain bound to this certificate; the commitment becomes part of the content every signer signs and is anchored with the certificate, and the proof is verified on zkVerify. A set with any open finding is refused, and the circuit itself can't prove one. The response's findingsClosure.salt is returned once and never stored: keep it with the findings, so an auditor can recompute the commitment:
context = sha256("attest.certificate.findings|" + certificateId) mod p
id_i = sha256("attest.finding|" + findingId) mod p (chain in ascending id_i order)
acc_0 = Poseidon(context, n, salt)
acc_{i+1} = Poseidon(acc_i, id_i, severity_i, 1) severity: info 0 … critical 4
commitment = acc_n (p = the BN254 scalar field)The certificate's page verifies the proof in the visitor's browser, against the published verification key, and shows zkVerify's verdict.
/api/v1/certificates/{id}Publicawaiting_signatures, issuing, issued, revoked), content, signers, the PDF's SHA-256, the credential, and the anchor. Public once issued; before that, only to the issuer's members and signers./api/v1/certificatesAPI keyorgId, limit)./api/v1/certificates/{id}API key{ "searchable": true }. Off by default. Listed certificates appear in https://attest.datahubz.com/sitemap-certificates.xml while they are current; a revoked, replaced or expired one leaves search results on its own./api/v1/certificates/{id}/revokeAPI key{ "reason": "..." }. The reason is shown on its page, and the revocation is anchored too.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 (a sealed call lists its reviewers' keys with each one's emailHash), and check every key against the public key directory before wrapping anything to it, as the dashboard does.
{ "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.drand.chain, drand.round), the reviewers' public keys and key ids to seal to, and every submission's receipt (no names)./api/v1/sealed/{id}/submissionsAPI key{ 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./api/v1/sealed/{id}/submissionsAPI key/api/v1/sealed/{id}/openedAPI keyApproval 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 the document and its keys as ciphertext. The terms (approvers, threshold, recipients, date, document fingerprint) are anchored at creation, and every approval or decline when it's made.
{ "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./api/v1/releasesAPI key{ 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./api/v1/releases/{id}/cancelAPI keyApproving 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.
/api/v1/vaultsAPI key{ 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.?open=1 after release to receive the items and the trustees' shares (from then on, a check-in can't lock it again)./api/v1/vaults/{id}API keyitems: [{ id, wrappedDek }] re-wrapping every item's key. Outstanding confirmations are cancelled, and it counts as a check-in./api/v1/vaults/{id}API key/api/v1/vaults/{id}/itemsAPI key{ ciphertext, iv, metaCiphertext, metaIv, wrappedDek }, up to 3 MB each and 20 per vault./api/v1/vaults/{id}/items/{itemId}API keyDELETE 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).
/api/v1/formsAPI key{ 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.cardKinds: the card types your organization has issued, for card eligibility.DELETE cancels it (the organizer, before its results are anchored)./api/v1/forms/{id}/openAPI key/api/v1/forms/{id}/closeAPI key/api/v1/forms/{id}/responsesPublic{ 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.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.
Integrations
Platforms that serve many organizations (a hotel system, a compliance platform) use the Integrations API instead of an account key: an integration key (ik_live_…) registers organizations, each with its own wallet, records events as one transaction each, and records daily state proofs verified on zkVerify. It has its own guide, with endpoints, webhooks and a TypeScript client: Integrations.
Kept with the person, by design
- Keys and passkeys. Your private key is created and unlocked in your browser with a passkey, and reaches our servers only encrypted, so we can't use it. Set it up once on the Security page. Your public key is listed in the public key directory; the Security page shows your key fingerprint, checks your own entry in every published root, and gives you safety numbers to compare with the people you share with.
- Opening documents. Decryption happens where your key is. Through the API you receive ciphertext and decrypt it yourself.
- Anything signed with a passkey. Signing an agreement (in the dashboard, from a signer's emailed link, or through 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 or the device PIN), so no API key alone can do them on anyone's behalf. Each is verifiable by anyone:
POST /api/v1/verifywith anagreementIdreturns 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.