Skip to content

Node SDK

View .md

@exalto/proof wraps the REST API for Node.js servers. Every method maps to one endpoint, takes the same fields and returns the same objects, so the REST reference describes the SDK too. Use it only on your server: it holds your secret key.

Terminal window
npm install @exalto/proof

It needs Node.js 20 or later and has no dependencies. Version 1.x of the package talks to /v1 of the API.

proof.ts
import Proof from "@exalto/proof";
export const proof = new Proof(process.env.PROOF_SECRET_KEY);

The key decides the mode: an sk_test_ key works on test data, an sk_live_ key on live data. proof.platform.retrieve() says which, as mode.

The second argument takes options:

Option Default What it does
maxRetries 2 How many times a request answered with 429 or 5xx is tried again.
baseUrl https://api.proof.exalto.ai/v1 Where requests go. Change it only for a proxy, or a Proof you run yourself.
const proof = new Proof(process.env.PROOF_SECRET_KEY, { maxRetries: 4 });

The SDK uses the API’s own field names, in snake_case, in both directions. What you send to proof.documents.update is the body of PATCH /v1/documents/:id, and what comes back is the document object as the REST reference shows it.

await proof.documents.update("doc_8Hq2Lm4R", { link_access: "suggest" });

Each resource is a property of the client. Path parameters come first, then the body or query as one object.

See Users.

Method Endpoint
proof.users.upsert(externalId, params) PUT /v1/users/external/:external_id
proof.users.retrieve(userId) GET /v1/users/:user_id
proof.users.retrieveByExternalId(externalId) GET /v1/users/external/:external_id
proof.users.update(userId, params) PATCH /v1/users/:user_id
proof.users.delete(userId) DELETE /v1/users/:user_id
proof.users.merge(userId, { into }) POST /v1/users/:user_id/merge
proof.users.listDocuments(userId, { limit?, ids? }) GET /v1/users/:user_id/documents
proof.users.listTrash(userId) GET /v1/users/:user_id/documents?trashed=true
proof.users.emptyTrash(userId) DELETE /v1/users/:user_id/trash
proof.users.deleteFromTrash(userId, documentId) DELETE /v1/users/:user_id/trash/:document_id
proof.users.listAgents(userId, { document? }) GET /v1/users/:user_id/agents
proof.users.endSessions(userId, { group? }) DELETE /v1/users/:user_id/sessions
proof.users.retrieveCheckins(userId) GET /v1/users/:user_id/checkins
proof.users.updateCheckins(userId, { every_minutes }) PUT /v1/users/:user_id/checkins

See Documents.

Method Endpoint
proof.documents.create(params) POST /v1/documents
proof.documents.retrieve(documentId) GET /v1/documents/:id
proof.documents.update(documentId, params) PATCH /v1/documents/:id
proof.documents.trash(documentId, { user? }) POST /v1/documents/:id/trash
proof.documents.restore(documentId, { user? }) POST /v1/documents/:id/restore
proof.documents.delete(documentId) DELETE /v1/documents/:id
proof.documents.export(documentId, { format }) GET /v1/documents/:id/export
proof.documents.provenance(documentId) GET /v1/documents/:id/provenance

export resolves to the Markdown as a string.

See Members.

Method Endpoint
proof.members.list(documentId) GET /v1/documents/:id/members
proof.members.put(documentId, userId, { role }) PUT /v1/documents/:id/members/:user_id
proof.members.delete(documentId, userId) DELETE /v1/documents/:id/members/:user_id

See Sessions.

Method Endpoint
proof.sessions.create({ user, document, via?, group? }) POST /v1/sessions
proof.sessions.create({ user, purpose: "presence", group? }) POST /v1/sessions

See Agents.

Method Endpoint
proof.agents.retrieve(agentId) GET /v1/agents/:agent_id
proof.agents.update(agentId, { access }) PATCH /v1/agents/:agent_id
proof.agents.listDocuments(agentId) GET /v1/agents/:agent_id/documents
proof.agents.putDocument(agentId, documentId, { role }) PUT /v1/agents/:agent_id/documents/:document_id
proof.agents.deleteDocument(agentId, documentId) DELETE /v1/agents/:agent_id/documents/:document_id
proof.agents.revoke(agentId) DELETE /v1/agents/:agent_id

See Agent requests.

Method Endpoint
proof.agentRequests.retrieve(requestId) GET /v1/agent-requests/:id
proof.agentRequests.approve(requestId, params) POST /v1/agent-requests/:id/approve
proof.agentRequests.deny(requestId) POST /v1/agent-requests/:id/deny

See Reader requests.

Method Endpoint
proof.readerRequests.retrieve(requestId) GET /v1/reader-requests/:id
proof.readerRequests.approve(requestId, { user, email }) POST /v1/reader-requests/:id/approve

See Seals.

Method Endpoint
proof.seals.create(documentId, { user }) POST /v1/documents/:id/seals
proof.seals.list(documentId) GET /v1/documents/:id/seals
proof.seals.retrieve(code) GET /v1/seals/:code
proof.seals.withdraw(code) DELETE /v1/seals/:code

See Webhook endpoints.

Method Endpoint
proof.webhookEndpoints.create({ url, events }) POST /v1/webhook-endpoints
proof.webhookEndpoints.list() GET /v1/webhook-endpoints
proof.webhookEndpoints.rollSecret(endpointId) POST /v1/webhook-endpoints/:id/roll-secret
proof.webhookEndpoints.delete(endpointId) DELETE /v1/webhook-endpoints/:id

See Platform.

Method Endpoint
proof.platform.retrieve() GET /v1/platform
proof.platform.update(params) PATCH /v1/platform
proof.platform.setTools({ url, secret, tools, instructions? }) PUT /v1/platform/tools
proof.platform.stats() GET /v1/platform/stats

Methods that list things return a page you can either await or iterate. Awaiting it gives you one page, as the API returns it:

const page = await proof.users.listDocuments("usr_5Kq2Lm8R", { limit: 20 });
page.data; // up to 20 documents, newest edited first
page.next; // a cursor, or null on the last page

Iterating it with for await fetches the following pages as you go, so you see every item once:

for await (const doc of proof.users.listDocuments("usr_5Kq2Lm8R")) {
console.log(doc.title);
}

Stop the loop with break whenever you have what you need; no further pages are fetched.

A request the API refuses throws a ProofError. It carries the API’s error fields and the HTTP status:

Property Example What it is
type "permission" The kind of error, as listed in Errors.
code "no_access" A specific reason you can branch on.
message "usr_9Tb3Wc1X can't open doc_8Hq2Lm4R." A sentence for your logs, not for your users.
param "role" The field at fault, for invalid_request errors.
status 403 The HTTP status.
import Proof, { ProofError } from "@exalto/proof";
try {
const session = await proof.sessions.create({ user: "usr_9Tb3Wc1X", document: "doc_8Hq2Lm4R" });
} catch (error) {
if (error instanceof ProofError && error.code === "no_access") {
// Grace isn't a member, and the document's link access doesn't let Grace in.
} else {
throw error;
}
}

Network failures throw the error fetch threw.

The client retries a request that was rate limited (429), and one other than a POST that failed on our side (500 and above), maxRetries times. It waits longer before each try, and as long as Retry-After asks when the API sends it. A POST that failed may have been carried out, so it isn’t sent again: see Idempotency.

Every id the client puts in a path is encoded as one segment, and an id of . or .. throws a TypeError: an id that came from your users can’t name another endpoint.

proof.webhooks.verify checks a delivery’s signature and returns the event, or throws an Error. Give it the body exactly as it arrived, before any JSON parsing:

webhooks.ts
import express from "express";
import { proof } from "./proof";
const app = express();
app.post("/proof/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const event = proof.webhooks.verify(req.body, req.get("Proof-Signature") ?? "", process.env.PROOF_WEBHOOK_SECRET!);
if (event.type === "mention.created") notifyMention(event.data);
res.sendStatus(204);
});

It also refuses a delivery whose timestamp is more than five minutes from your clock. Webhook events lists every event and its payload.

Calls to your own agent tools are signed the same way. verifySignature(rawBody, header, secret) checks one and says whether it matched:

import { verifySignature } from "@exalto/proof";
if (!verifySignature(rawBody, req.get("Proof-Signature"), process.env.ACME_TOOLS_SECRET!)) return res.sendStatus(401);

The package ships its own types. Each object the API returns has a type of the same name:

import type { Agent, Document, Event, Member, Platform, Role, Seal, Session, User } from "@exalto/proof";

Event<T> is the webhook envelope, with data typed as T; pass the payload type of the events you handle:

const event = proof.webhooks.verify<{ seal: Seal }>(rawBody, header, secret);
if (event.type === "seal.created") await saveSeal(event.data.seal.code, event.data.seal.url);