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/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. 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 body
{ "error": { "code": "quota_exceeded", "message": "Free plan limit of 10 records this month reached." } }
ParameterTypeDescription
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.
402 signing_allowance_exceededCreating an agreement: the free plan's 2 documents sent for signature, in total, are used.
403 / 404Not yours, private, or doesn't exist.
409Not possible in the current state (for example, not your turn to sign).
429 rate_limitedToo many requests: wait for Retry-After seconds, then retry.
503 unavailableA 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.

POST/api/v1/receiptsAPI key Try it
Create a receipt.
ParameterTypeDescription
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, 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.

POST/api/v1/verifyPublic Try it
Send one of the following.
ParameterTypeDescription
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 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.

POST/api/v1/agreementsAPI key Try it
Create an agreement. The document arrives already encrypted (see the next section). Attest signs every PDF, in two tiers. Every document gets the anchored original: attached to its signed copy exactly as it was signed, and shown exactly by Attest's verifier. A clean document, one Attest can prove every PDF reader it tests (pdf.js, PDFium, MuPDF, Poppler, Ghostscript and Apple PDFKit) shows the same way, also gets a signed copy whose pages show exactly what its signers saw, in each of those readers. A copy of any other document is protected as far as Attest can and says that some PDF readers may display parts of it differently. A PDF protected with an owner password only, as many government forms are, is decrypted exactly as the signing page's PDF reader (pdf.js) opens it, and checked by its content. The agreement's documentCheck (yours to read) records what Attest's check found: { "clean": false, "codes": ["fonts"] } or null until known. It never holds anything up.
ParameterTypeDescription
titlestringRequired; it goes on the certificate, in the Latin, Greek, Cyrillic or Vietnamese alphabet.
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. Any PDF: none is refused.
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. A name goes on the signed copy and the certificate, in the Latin, Greek, Cyrillic or Vietnamese alphabet.
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.
fieldsarrayOptional, 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.
pageSizesarrayOptional: [{ 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).

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

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.
POST/api/v1/keys/directoryAPI key
Check a public key before you encrypt to it. Every encryption key is listed in Attest's public key directory, a sparse Merkle tree whose roots are published, in order and never replaced, on the AttestKeyDirectory 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.
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. 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

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" }, 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.
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 waiting for you to unlock the document (asked to sign, a key set up, not unlocked yet), with their public keys.
POST/api/v1/agreements/{id}/grantAPI key
Unlock it for them: unwrap your document key locally, check each public key against the key directory, re-wrap the document key to each, and send { "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.
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 the anchored root of the agreement's tree (65,536 agreements each), and verified independently on zkVerify.

POST/api/v1/agreement-proofsAPI key Try it
Generate a proof (takes a few seconds). The proof is returned right away; the root it was made against is anchored on Base right after, from Attest's platform wallet, and the proof's 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).
ParameterTypeDescription
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). ?agreementId= narrows both to one agreement.
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, 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.

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, its result and the commitment are stored, never the counts. The response carries zkClaim.outcomes.valid, the scanCommitment, and opening.salt, returned once: with the counts and scanners, it opens the commitment to an auditor.
ParameterTypeDescription
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 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.

POST/api/v1/credentialsAPI key Try it
Issue a card from one of your organizations. The recipient is emailed a link to accept it.
ParameterTypeDescription
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:

ParameterTypeDescription
/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.

ParameterTypeDescription
/.well-known/openid-credential-issuerOID4VCICredential issuer metadata: one ldp_vc configuration per card type.
/.well-known/oauth-authorization-server/oid4vciOAuthWallet authorization server metadata (issuer /oid4vci): pushed requests, authorization and the token 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.
ParameterTypeDescription
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.

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.

POST/api/v1/certificatesAPI key
Create one for an organization you own. Returns it with status: "awaiting_signatures" and a signUrl; each signer is emailed a link. The last signature issues it. Uses one record.
ParameterTypeDescription
orgIdstringThe issuing organization, by id or slug (you must be an owner).
title, subjectName, statementstringWhat is certified, about whom, in one headline statement.
asOfISO 8601The point in time the statements are about (not in the future).
sectionsarray[{ heading, body?, items?: [{ label, detail?, ref?, result? }] }]: the full statements and checks, up to 60 sections.
signersarray[{ email, name, role? }]: 1 to 10 people who sign with their Attest passkey.
languagestringen (default) or es: the page and PDF language.
subjectDomain, validUntil, referenceoptionalThe subject's domain, an expiry, and your own id for it.
attachmentsarray[{ name, sha256 }]: documents the certificate covers; their hash finds it.
supersedesstringAn issued certificate this one replaces.
searchablebooleanDefault false. True lists its public page in search results (Google, Bing, AI assistants) while it is issued, unrevoked, not replaced and not expired.
findingsClosureobject{ 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:

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

GET/api/v1/certificates/{id}Public
Status (awaiting_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.
GET/api/v1/certificatesAPI key
Your organizations' certificates, newest first (orgId, limit).
PATCH/api/v1/certificates/{id}API key
An owner of the issuing organization, or the platform that created it (integration key), turns search listing on or off: { "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.
POST/api/v1/certificates/{id}/revokeAPI key
An owner revokes an issued certificate: { "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.

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

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.

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