# Kovasign Document API — TypeScript

The Document API is headless parity with the Kovasign platform: whatever a
sender can do by clicking through the app, an Enterprise tenant's own code
can do by calling this API. It requires an Enterprise plan and a Document
API token minted in Workspace → Developers → API & webhooks ("Connect your
software" there hands you all of these values as a ready .env block).

- Base URL: `https://app.kovasign.com/api/v1` (`KOVASIGN_API_URL`)
- Every `/documents*` and `/templates*` call needs three headers:
  - `Authorization: Bearer kvs_doc_…` (`KOVASIGN_API_TOKEN`)
  - `X-On-Behalf-Of: <member email or profile uuid>` (`KOVASIGN_ON_BEHALF_OF`) — the
    real person the action is attributed to. **Not** sent on `/webhooks*`.
  - `Idempotency-Key: <string ≤128 chars>` — required on `POST /documents`,
    `POST /documents/{id}/send`, and `POST /webhooks/{id}/test`; optional
    (but recommended) on every other write.
    **One key per attempt, not per record:** generate a fresh key when you start
    an action, store it with the exact body (including `confirmed_at`), and retry
    a timeout/5xx with the same key and the byte-identical body. Any new intent
    (re-send after a recall, a new document after a void) needs a new key — an
    old key replays the old response, or 422s `idempotency_key_reused` if the
    body changed. Keys are remembered for at least 24 hours.
  - Sign-then-send needs the company's role to have the lowest `signing_order`
    (Acme HR is step 1 below). Signing a draft emails nobody; `/send` invites the
    first step that still has an unsigned signer.

Everything below uses only Node 18+ built-ins: `fetch`, `FormData`, `Blob`,
`node:fs`, `node:crypto`, `node:http`. No npm dependencies.

```ts
const KOVASIGN_API_URL = process.env.KOVASIGN_API_URL ?? "https://app.kovasign.com/api/v1"
const KOVASIGN_API_TOKEN = process.env.KOVASIGN_API_TOKEN!
const KOVASIGN_ON_BEHALF_OF = process.env.KOVASIGN_ON_BEHALF_OF!

/** Shared headers for every /documents* and /templates* call. */
function authHeaders(idempotencyKey?: string): Record<string, string> {
  const headers: Record<string, string> = {
    Authorization: `Bearer ${KOVASIGN_API_TOKEN}`,
    "X-On-Behalf-Of": KOVASIGN_ON_BEHALF_OF,
  }
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey
  return headers
}
```

## 1. Create a document with placement tags

Small response types for the shapes we read back.

```ts
interface Recipient {
  id: string
  role: string
  name: string
  email: string
  type: "signer" | "viewer"
  signing_order: number
  status: string
}

interface PlacedField {
  field_id: string
  recipient_id: string
  type: string
  page: number
  x: number
  y: number
  width: number
  height: number
  via: "tag" | "anchor" | "coords"
  tag?: string
}

interface CreateDocumentResponse {
  document: { id: string; name: string; status: string } | null
  recipients: Recipient[]
  placed: PlacedField[]
  warnings: { field: string; code: string }[]
}

interface PlacementUnresolvedError {
  error: string
  code: "placement_unresolved"
  errors: { field: string; page?: number; reason: string; hint?: string; candidates?: string[] }[]
}
```

Upload a PDF whose text already contains `{{sig:signer1}} {{date:signer1}} {{sig:signer2}} {{text:signer2:title}}`, map the two roles, and place fields from the tags — `multipart/form-data` with a `file` part and a `meta` JSON part.

```ts
import { readFile } from "node:fs/promises"

async function createDocumentFromFile(): Promise<CreateDocumentResponse> {
  const bytes = await readFile("offer-letter.pdf")
  const form = new FormData()
  form.set("file", new Blob([bytes], { type: "application/pdf" }), "offer-letter.pdf")
  form.set(
    "meta",
    JSON.stringify({
      name: "Offer — J. Tan",
      roles: {
        signer1: { name: "Jane Tan", email: "jane@x.com", signing_order: 2 },
        signer2: { name: "Acme HR", email: "hr@acme.com", signing_order: 1 },
      },
      placement: { tags: true },
      expires_in_days: 7,
    }),
  )

  const res = await fetch(`${KOVASIGN_API_URL}/documents`, {
    method: "POST",
    headers: authHeaders("offer-8812"),
    body: form,
  })

  if (res.status === 422) {
    const body = (await res.json()) as PlacementUnresolvedError
    // No document was created — the draft is rolled back on any unresolved field.
    throw new Error(`placement_unresolved: ${body.errors.map((e) => e.reason).join(", ")}`)
  }
  if (!res.ok) throw new Error(`create failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<CreateDocumentResponse>
}
```

A `201` looks like:

```json
{
  "document": { "id": "d1", "name": "Offer — J. Tan", "status": "draft" },
  "recipients": [{ "id": "r1", "role": "signer1", "name": "Jane Tan", "email": "jane@x.com", "type": "signer", "signing_order": 2, "status": "pending" }],
  "placed": [{ "field_id": "f1", "recipient_id": "r1", "type": "signature", "page": 3, "x": 12.4, "y": 71.0, "width": 15, "height": 5.2, "via": "tag", "tag": "{{sig:signer1}}" }],
  "warnings": []
}
```

## 2. Create from a URL or from a template

From a public HTTPS URL (SSRF-checked) instead of an upload:

```ts
async function createDocumentFromUrl(): Promise<CreateDocumentResponse> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents`, {
    method: "POST",
    headers: { ...authHeaders("offer-8812-url"), "Content-Type": "application/json" },
    body: JSON.stringify({
      source: { url: "https://cdn.acme.com/offer.pdf" },
      name: "Offer — J. Tan",
      roles: {
        signer1: { name: "Jane Tan", email: "jane@x.com", signing_order: 2 },
        signer2: { name: "Acme HR", email: "hr@acme.com", signing_order: 1 },
      },
      placement: { tags: true },
      expires_in_days: 7,
    }),
  })
  if (!res.ok) throw new Error(`create failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<CreateDocumentResponse>
}
```

From a saved template — only the roles you name are overridden; the rest keep the template's remembered name/email:

```ts
async function createDocumentFromTemplate(): Promise<CreateDocumentResponse> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents`, {
    method: "POST",
    headers: { ...authHeaders("offer-8812-tmpl"), "Content-Type": "application/json" },
    body: JSON.stringify({
      template_id: "tmpl_1",
      name: "Offer — J. Tan",
      roles: { signer1: { name: "Jane Tan", email: "jane@x.com" } },
    }),
  })
  if (!res.ok) throw new Error(`create failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<CreateDocumentResponse>
}
```

## 3. Sender signs first

Small types for the document read.

```ts
interface DocumentField {
  id: string
  recipient_id: string
  field_type: "signature" | "initials" | "date" | "text" | "checkbox" | "stamp"
  page_number: number
  requires_singpass: boolean
}

interface GetDocumentResponse {
  document: { id: string; name: string; status: string; progress: { signedCount: number; totalSigners: number } }
  fields: DocumentField[]
}
```

Fetch the document, find the sender's own date field, sign it, and fill the field in the same call:

```ts
async function senderSignsFirst(documentId: string, senderRecipientId: string) {
  const getRes = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}`, { headers: authHeaders() })
  if (!getRes.ok) throw new Error(`get failed: ${getRes.status}`)
  const { fields } = (await getRes.json()) as GetDocumentResponse

  const dateField = fields.find((f) => f.recipient_id === senderRecipientId && f.field_type === "date")
  if (!dateField) throw new Error("sender date field not found")

  const signRes = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}/sign`, {
    method: "POST",
    headers: { ...authHeaders(), "Content-Type": "application/json" },
    body: JSON.stringify({
      intent: {
        user_confirmed: true,
        confirmed_at: new Date().toISOString(),
        surface: "acme-hris/v4",
        actor_ref: KOVASIGN_ON_BEHALF_OF,
      },
      field_values: { [dateField.id]: new Date().toISOString().slice(0, 10) },
    }),
  })
  // A Singpass-required slot answers 409 singpass_required — it can only be
  // signed by that recipient via their own link, never headlessly.
  if (!signRes.ok) throw new Error(`sign failed: ${signRes.status} ${await signRes.text()}`)
  return signRes.json() as Promise<{ signed: true; recipient_id: string; document_hash: string; message: string }>
}
```

## 4. Send

```ts
interface SendResponse {
  status: "pending"
  first_step: number
  total_recipients: number
  sent_to: number
  results: { recipient: string; success: boolean; error?: string }[]
}

// `attempt` is created ONCE per send attempt and persisted before the first
// call: { key: crypto.randomUUID(), confirmedAt: <when the human approved> }.
// Retry a timeout/5xx with the SAME attempt (same key, byte-identical body);
// a re-send after a recall is a NEW attempt with a new key.
async function sendDocument(
  documentId: string,
  attempt: { key: string; confirmedAt: string },
): Promise<SendResponse> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}/send`, {
    method: "POST",
    headers: { ...authHeaders(attempt.key), "Content-Type": "application/json" },
    body: JSON.stringify({
      attestation: {
        user_confirmed: true,
        confirmed_at: attempt.confirmedAt,
        surface: "acme-hris/v4",
      },
    }),
  })
  if (!res.ok) throw new Error(`send failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<SendResponse>
}
```

## 5. Poll for completion (and the webhook alternative)

```ts
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

async function waitForCompletion(documentId: string, pollMs = 5000): Promise<void> {
  for (;;) {
    const res = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}`, { headers: authHeaders() })
    if (!res.ok) throw new Error(`get failed: ${res.status}`)
    const { document } = (await res.json()) as GetDocumentResponse
    if (document.status === "completed") return
    await sleep(pollMs)
  }
}
```

Polling works, but it's simpler and faster to register a webhook once and react to `document.completed` as it happens — see §8 below for a full receiver.

## 6. Download the signed PDF and certificate

Bytes, written to disk, verified against `X-Content-SHA256`:

```ts
import { writeFile } from "node:fs/promises"
import { createHash } from "node:crypto"

async function downloadSignedPdf(documentId: string, outPath: string): Promise<void> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}/files/signed`, { headers: authHeaders() })
  if (!res.ok) throw new Error(`download failed: ${res.status} ${await res.text()}`) // 409 not_completed until every signer has signed
  const expectedSha256 = res.headers.get("x-content-sha256")
  const bytes = Buffer.from(await res.arrayBuffer())
  const actualSha256 = createHash("sha256").update(bytes).digest("hex")
  if (expectedSha256 && expectedSha256 !== actualSha256) throw new Error("sha256 mismatch")
  await writeFile(outPath, bytes)
}
```

Or the URL variant, which also returns the hash to verify against once you fetch the bytes:

```ts
interface FileUrlResponse {
  url: string
  expires_in: number
  sha256: string
}

async function getSignedFileUrl(documentId: string): Promise<FileUrlResponse> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}/files/signed?disposition=url`, {
    headers: authHeaders(),
  })
  if (!res.ok) throw new Error(`get url failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<FileUrlResponse>
}
```

The Signing Log Certificate uses the same two shapes at `/files/certificate`:

```ts
async function downloadCertificate(documentId: string, outPath: string): Promise<void> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}/files/certificate`, { headers: authHeaders() })
  if (!res.ok) throw new Error(`download failed: ${res.status} ${await res.text()}`)
  await writeFile(outPath, Buffer.from(await res.arrayBuffer()))
}
```

## 7. Templates and voiding

```ts
interface SaveAsTemplateResponse {
  template: {
    id: string
    name: string
    roles: { role_key: string; role_label: string; prefilled: boolean }[]
    field_count: number
  }
}

async function saveAsTemplate(documentId: string, name: string): Promise<SaveAsTemplateResponse> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}/save-as-template`, {
    method: "POST",
    headers: { ...authHeaders(), "Content-Type": "application/json" },
    body: JSON.stringify({ name }),
  })
  if (!res.ok) throw new Error(`save-as-template failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<SaveAsTemplateResponse>
}

interface Template {
  id: string
  name: string
  roles: { role_key: string; role_label: string; prefilled: boolean }[]
}

async function findTemplateByName(search: string): Promise<Template | undefined> {
  const res = await fetch(`${KOVASIGN_API_URL}/templates?search=${encodeURIComponent(search)}`, { headers: authHeaders() })
  if (!res.ok) throw new Error(`list templates failed: ${res.status}`)
  const body = (await res.json()) as { templates: Template[] }
  return body.templates[0]
}

/** Create a document from a template, overriding one prefilled role. */
async function createFromTemplate(templateId: string): Promise<CreateDocumentResponse> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents`, {
    method: "POST",
    headers: { ...authHeaders("offer-9001"), "Content-Type": "application/json" },
    body: JSON.stringify({
      template_id: templateId,
      name: "Offer — K. Lim",
      roles: { signer1: { name: "Kevin Lim", email: "kevin@x.com" } },
    }),
  })
  if (!res.ok) throw new Error(`create failed: ${res.status} ${await res.text()}`)
  return res.json() as Promise<CreateDocumentResponse>
}

async function voidDocument(documentId: string, reason: string): Promise<void> {
  const res = await fetch(`${KOVASIGN_API_URL}/documents/${documentId}/void`, {
    method: "POST",
    headers: { ...authHeaders(), "Content-Type": "application/json" },
    body: JSON.stringify({ reason }),
  })
  if (!res.ok) throw new Error(`void failed: ${res.status} ${await res.text()}`)
}
```

## 8. Webhook receiver — verify `Kovasign-Signature`

Port of the reference verifier in `docs/runbooks/tenant-seat-webhooks-fixtures.md` §4
(shared with `docs/runbooks/document-webhooks.md` §2/§4). Rules: parse
`t=…,v1=…[,v1=…]`; reject if `|now − t| > 300s`; HMAC-SHA256 over
`"<t>.<raw body>"`; constant-time compare; accept if **any** `v1` matches
**any** secret you currently hold (both terms are valid for 24h after a
rotation); dedupe on `event_id`; apply an event only when its `sequence` is
greater than the last one applied for that `document_id`; always ack `200`
for a duplicate or an out-of-order event, otherwise Kovasign retries it.

```ts
import { createHmac, timingSafeEqual } from "node:crypto"
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"

const TOLERANCE_SEC = 300 // 5-minute replay window

/** Verbatim port of the reference verifier — verifies over the RAW body, never a re-serialised parse. */
function verifyKovasignSignature(
  rawBody: string,
  signatureHeader: string,
  secrets: string[],
  nowSec: number = Math.floor(Date.now() / 1000),
): boolean {
  // signatureHeader: "t=1758700502,v1=<hex>[,v1=<hex>]"
  const parts = signatureHeader.split(",").map((p) => p.split("="))
  const t = parts.find(([k]) => k === "t")?.[1]
  const v1s = parts.filter(([k]) => k === "v1").map(([, v]) => v)
  if (!t || v1s.length === 0) return false
  if (Math.abs(nowSec - Number(t)) > TOLERANCE_SEC) return false

  const signed = `${t}.${rawBody}`
  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(signed).digest("hex")
    for (const v1 of v1s) {
      if (v1.length === expected.length && timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
        return true
      }
    }
  }
  return false
}

interface DocumentWebhookEvent {
  id: string
  type: string
  api_version: string
  tenant_id: string
  document_id?: string
  recipient_id?: string
  actor?: { user_id: string; via: "web" | "api" | "mcp" | "system" | "signer" }
  occurred_at: string
  sequence: number
  data: Record<string, unknown>
}

// Held secrets for this endpoint — both current and (during a rotation) previous.
const WEBHOOK_SECRETS = [process.env.KOVASIGN_WEBHOOK_SECRET!].filter(Boolean)

// In-memory dedupe + ordering state. Use a real DB table in production
// (event_id PRIMARY KEY; last_sequence per document_id).
const seenEventIds = new Set<string>()
const lastSequenceByDocument = new Map<string, number>()

function readRawBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = []
    req.on("data", (chunk: Buffer) => chunks.push(chunk))
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
    req.on("error", reject)
  })
}

async function handleWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
  const rawBody = await readRawBody(req)
  const signatureHeader = req.headers["kovasign-signature"]

  if (typeof signatureHeader !== "string" || !verifyKovasignSignature(rawBody, signatureHeader, WEBHOOK_SECRETS)) {
    res.writeHead(400).end("invalid signature")
    return
  }

  const event = JSON.parse(rawBody) as DocumentWebhookEvent

  // Dedupe on event_id — always ack 200 for a duplicate so Kovasign stops retrying it.
  if (seenEventIds.has(event.id)) {
    res.writeHead(200).end("duplicate")
    return
  }
  seenEventIds.add(event.id)

  // Order on sequence, never on occurred_at. Apply only when strictly greater
  // than the last sequence applied for this document; still ack 200 otherwise.
  if (event.document_id) {
    const lastSequence = lastSequenceByDocument.get(event.document_id) ?? -1
    if (event.sequence <= lastSequence) {
      res.writeHead(200).end("out of order, discarded")
      return
    }
    lastSequenceByDocument.set(event.document_id, event.sequence)
  }

  // Apply the event — e.g. react to document.completed by downloading the signed PDF.
  console.log(`applied ${event.type} for document ${event.document_id} (sequence ${event.sequence})`)

  res.writeHead(200).end("ok")
}

createServer((req, res) => {
  if (req.method === "POST") {
    handleWebhookRequest(req, res).catch((err) => {
      console.error(err)
      res.writeHead(500).end("internal error")
    })
  } else {
    res.writeHead(404).end()
  }
}).listen(3001, () => console.log("webhook receiver listening on :3001"))
```
