Esign
Package esign is a document out for signature, signed and filed with an audit trail.
Package esign is a document out for signature, signed and filed with an audit trail.
| Base URL | https://api.hanzo.ai |
| Operations | 13 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1125 · Esign — Documents Out for Signature — Draft · read the specification →
/v1/esign is a document out for signature: upload a PDF, place recipients and
fields, send it, and file the signed result with an audit trail — a real
cryptographically sealed PDF comes out. It is implemented in hanzoai/cloud at
apps/esign, running the ported Documenso-lineage domain logic in-process.
This HIP states the two doors — the sender's and the recipient's — the store
each opens, and where the cryptography lives.
Motivation
The upstream product was a Next.js pod with Prisma and Postgres; the fold
retires it, and the standalone pod held no tenant data — zero documents,
recipients or users — so cloud's per-tenant store is authoritative from the
first write with nothing to migrate (apps/esign/esign.go:34-39). What
remains worth specifying is the part that moves legally binding documents: who
may open which store, and what seals the PDF.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The store
Rows — documents, recipients, fields, the signing state machine, the audit
trail — live in one SQLite file per tenant, opened by the shared goja host with
one transaction per request (apps/esign/esign.go:13-15). Beside the tenant
files sits one system-namespace index, token_index
(apps/esign/index.go:34) — the signing-token → tenant routing table, the
single deliberately cross-tenant piece.
§2 The two doors
Owner routes (/v1/esign/documents/*) require a validated principal and
resolve the tenant from it (apps/esign/typed.go:83, HIP-0026). Recipient
routes (/v1/esign/o/{org}/sign/{token}/*) are unauthenticated capability
links: the crypto-random token is the whole credential, and it — resolved
through the token index before any per-tenant store opens — is what selects the
tenant DB. The {org} segment is only the caller's claim, checked against that
answer (apps/esign/typed.go:87-115). The host pre-routes the bundle's database
to the resolved tenant, so isolation is a host property, not bundle discipline.
§3 The typed surface
All thirteen operations are typed ops (apps/esign/typed.go:1054-1082), so each
is one registry entry carrying its route, its schema, its MCP tool, its CLI
command and its generated SDK method. The premise this section once rested on —
every route is built by a handler factory closing over a bundle route name, and
a closure has no doc comment for the registry to lift — is a fact about the
FACTORY and not about the routes: written as methods over the same bundle seam,
on the shared apps/goja kit, the ops carry their own. The ledger of operations
exempted from typing is therefore empty, and a gate keeps it empty or makes the
next entry state its wire fact (apps/esign/typed_wire_test.go:29-40).
The bundle still decides every answer and the Go host only carries it, so typing had to move neither half, and each half is pinned:
- What it answers. The bundle's value crosses the goja boundary as a map and
is re-marshalled by encoding/json, which sorts object keys, so every model
declares its fields in alphabetical json-tag order and the typed answer is
byte-identical to the relay's own bytes rather than merely equal as JSON
(
TestTypedAnswersAreByteIdentical,apps/esign/typed_wire_test.go:307). - What it accepts. The bundle validates with coercing helpers, so a Go
stringorintfield would refuse input the route accepts today. Every caller-supplied field is a scalar carried through byte for byte, leaving the bundle the only judge of it, and a page or a signing order stays the number the caller sent (:349,:626). - How it refuses. The bundle authors its own envelope,
{"error": …}, under its own status;goja.BundleErrcarries both andgoja.Envelopewrites them back verbatim, so a 409recipients can only be added while DRAFTreads exactly as it did before (:449).
§4 The seal
PDF and PKI are the one capability the bundle cannot provide, so they are Go
host functions injected as __pdf = { stamp, sign }
(apps/esign/esign.go:17-22): page stamping through pdfcpu and an x509/PKCS#7
seal through digitorus/pdfsign. The signing logic and seal orchestration stay
in the bundle; only the crypto/PDF primitive is Go.
§5 Money, events, telemetry
esign is free, in those words (plugin/esign/main.go:21, cloud.Free; not in
spend.go:278). It publishes no events on the bus — the audit trail is rows in
the tenant DB, read back through /v1/esign/documents/{id}/audit — and it
emits nothing to observability beyond the request span every route gets.
§6 Stage
esign is beta: a vertical application, not the agentic-OS core. The manifest
row declares it (manifest/apps.go:411, Stage: Beta), so the capability is
reached by flag (HIP-0139 §8).
§7 Upstream
esign embeds github.com/hanzoai/sign v1.0.0 — the Documenso-lineage domain
logic ported to a self-contained goja bundle, AGPL-3.0 (the module's LICENSE).
What survives in HEAD is the signing domain: documents, recipients, fields,
flow, audit, completion. The Go leaf adds the two host primitives:
github.com/pdfcpu/pdfcpu v0.11.0 (Apache-2.0) for rendering and
github.com/digitorus/pdfsign (BSD-2-Clause) for the PKCS#7 seal.
Rationale
A capability link, rather than recipient accounts, is what makes the product usable: the counterparty signing an NDA has no reason to hold an identity here. The cost is that the token is a bearer credential, which is why it is crypto-random, why it selects the tenant rather than trusting the URL's org claim, and why the index resolves before any store opens — the link can be leaked, but it can only ever open the one signing session it names.
Security Considerations
The wrong implementation forges signatures or leaks documents across tenants.
The seal is Go-side PKI the bundle cannot reach around — a bundle defect can
mis-order a flow but cannot mint a seal. Cross-tenant reach is confined to the
token index, whose answer is a tenant name, never rows; the org segment in the
URL is checked against it, so a crafted URL naming another org resolves to
nothing. A boot that cannot carry the pre-rename data directory forward aborts
rather than serving an empty store over signed documents
(apps/esign/esign.go:77-83).
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | esign at its own prefix | 13 operations |
| CLI | hanzo esign … | 13 of 13 |
| SDK | EsignApi in every published client | 9 of 13 — the clients are generated at their own release |
| MCP | tool esign on https://api.hanzo.ai/v1/mcp | 9 operations, 1 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/esign/health, operation get_esign_health:
hanzo esign healthimport { Configuration, EsignApi } from 'hanzoai';
const api = new EsignApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getEsignHealth();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import EsignApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = EsignApi(client).get_esign_health()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.EsignAPI.GetEsignHealth(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, esign_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = esign_api::get_esign_health(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.EsignApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new EsignApi(client).getEsignHealth();curl https://api.hanzo.ai/v1/esign/health \
-H "Authorization: Bearer $HANZO_API_KEY"Tool esign, op get_esign_health — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "esign",
"arguments": {
"op": "get_esign_health",
"input": {}
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/esign/documents/{id}/audit | Returns the document's full audit trail, oldest first. |
GET /v1/esign/documents/{id}/download | Returns the document — the sealed PDF once it is complete. |
POST /v1/esign/documents/{id}/fields | Places a field on the page for one recipient to fill. |
POST /v1/esign/documents/{id}/recipients | Adds someone to a draft and mints their signing token. |
POST /v1/esign/documents/{id}/send | Sends the document out and answers each signer's link. |
GET /v1/esign/documents/{id} | Returns one document with its recipients and field layout. |
GET /v1/esign/documents | Returns your org's documents, newest first. |
POST /v1/esign/documents | Uploads a PDF and opens a draft ready for recipients and fields. |
GET /v1/esign/health | Reports whether the e-signature surface is mounted. |
POST /v1/esign/o/{org}/sign/{token}/complete | Finishes your signing — and seals the document if you were the last. |
POST /v1/esign/o/{org}/sign/{token}/fields/{fieldId} | Fills in one of your fields. |
POST /v1/esign/o/{org}/sign/{token}/reject | Declines to sign, with an optional reason. |
GET /v1/esign/o/{org}/sign/{token} | Opens a document you were asked to sign, using your signing link. |
How is this guide?