# eSign

URL: https://docs.hanzo.ai/docs/openapi/esign

> A document out for signature, signed and filed with an audit trail.

Also for this capability: **API** · [CLI](https://docs.hanzo.ai/docs/cli/esign) · [MCP](https://docs.hanzo.ai/docs/mcp-tools/esign) · [SDKs](https://docs.hanzo.ai/docs/sdks)

A document out for signature, signed and filed with an audit trail.

> [All API references →](https://docs.hanzo.ai/docs/openapi) · [Get started →](https://docs.hanzo.ai/docs/guides)

|                |                                        |
| -------------- | -------------------------------------- |
| **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 →](https://github.com/hanzoai/hips/blob/7ed737180339cd05b5f2935eafe7f7b83d2449a2/HIPs/hip-1125-esign-signatures.md)

`/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
  `string` or `int` field 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.BundleErr` carries both and `goja.Envelope` writes them
  back verbatim, so a 409 `recipients can only be added while DRAFT` reads
  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

```bash
export HANZO_API_KEY=sk-...   # console.hanzo.ai → API keys
```

Then the first call — a read that needs nothing but the key. `GET /v1/esign/health`, operation `get_esign_health`:

**CLI**

```bash
hanzo esign health
```

**SDK**

**.ts**

```ts
import { Configuration, EsignApi } from 'hanzoai';

const api = new EsignApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getEsignHealth();
```

**.py**

```python
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()
```

**.go**

```go
cfg := hanzoai.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := hanzoai.NewAPIClient(cfg)

resp, _, err := client.EsignAPI.GetEsignHealth(context.Background()).Execute()
if err != nil {
	return err
}
```

**.rs**

```rust
use hanzo_client::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?;
```

**.java**

```java
import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.EsignApi;

ApiClient client = new ApiClient();
client.setBearerToken(System.getenv("HANZO_API_KEY"));

var result = new EsignApi(client).getEsignHealth();
```

**HTTP**

```bash
curl https://api.hanzo.ai/v1/esign/health \
  -H "Authorization: Bearer $HANZO_API_KEY"
```

**MCP**

Tool `esign`, op `get_esign_health` — POST the JSON-RPC envelope to `https://api.hanzo.ai/v1/mcp`.

```bash
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`](https://docs.hanzo.ai/docs/openapi/esign/get-esign-documents-by-id-audit)                                       | Returns the document's full audit trail, oldest first.               |
| [`GET /v1/esign/documents/{id}/download`](https://docs.hanzo.ai/docs/openapi/esign/get-esign-documents-by-id-download)                                 | Returns the document — the sealed PDF once it is complete.           |
| [`POST /v1/esign/documents/{id}/fields`](https://docs.hanzo.ai/docs/openapi/esign/post-esign-documents-by-id-fields)                                   | Places a field on the page for one recipient to fill.                |
| [`POST /v1/esign/documents/{id}/recipients`](https://docs.hanzo.ai/docs/openapi/esign/post-esign-documents-by-id-recipients)                           | Adds someone to a draft and mints their signing token.               |
| [`POST /v1/esign/documents/{id}/send`](https://docs.hanzo.ai/docs/openapi/esign/post-esign-documents-by-id-send)                                       | Sends the document out and answers each signer's link.               |
| [`GET /v1/esign/documents/{id}`](https://docs.hanzo.ai/docs/openapi/esign/get-esign-documents-by-id)                                                   | Returns one document with its recipients and field layout.           |
| [`GET /v1/esign/documents`](https://docs.hanzo.ai/docs/openapi/esign/get-esign-documents)                                                              | Returns your org's documents, newest first.                          |
| [`POST /v1/esign/documents`](https://docs.hanzo.ai/docs/openapi/esign/post-esign-documents)                                                            | Uploads a PDF and opens a draft ready for recipients and fields.     |
| [`GET /v1/esign/health`](https://docs.hanzo.ai/docs/openapi/esign/get-esign-health)                                                                    | Reports whether the e-signature surface is mounted.                  |
| [`POST /v1/esign/o/{org}/sign/{token}/complete`](https://docs.hanzo.ai/docs/openapi/esign/post-esign-o-by-org-sign-by-token-complete)                  | Finishes your signing — and seals the document if you were the last. |
| [`POST /v1/esign/o/{org}/sign/{token}/fields/{fieldId}`](https://docs.hanzo.ai/docs/openapi/esign/post-esign-o-by-org-sign-by-token-fields-by-fieldid) | Fills in one of your fields.                                         |
| [`POST /v1/esign/o/{org}/sign/{token}/reject`](https://docs.hanzo.ai/docs/openapi/esign/post-esign-o-by-org-sign-by-token-reject)                      | Declines to sign, with an optional reason.                           |
| [`GET /v1/esign/o/{org}/sign/{token}`](https://docs.hanzo.ai/docs/openapi/esign/get-esign-o-by-org-sign-by-token)                                      | Opens a document you were asked to sign, using your signing link.    |

---

[All Hanzo APIs](https://docs.hanzo.ai/docs/openapi) · [Interactive reference](https://docs.hanzo.ai/docs/openapi/reference)
