# Kovasign Document API — curl

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.

```bash
export KOVASIGN_API_URL="https://app.kovasign.com/api/v1"
export KOVASIGN_API_TOKEN="kvs_doc_…"
export KOVASIGN_ON_BEHALF_OF="hr@acme.com"
```

## 1. Create a document with placement tags

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 in one call — `multipart/form-data` with a `file` part and a `meta` JSON part.

```bash
curl -sS -X POST "$KOVASIGN_API_URL/documents" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Idempotency-Key: offer-8812" \
  -F "file=@offer-letter.pdf;type=application/pdf" \
  -F 'meta={
    "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
  };type=application/json'
```

Success is `201`:

```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" },
    { "id": "r2", "role": "signer2", "name": "Acme HR", "email": "hr@acme.com", "type": "signer", "signing_order": 1, "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": []
}
```

If any tag can't be resolved (unmapped role, ambiguous anchor, Singpass overlap, …) the whole request fails with `422 placement_unresolved` and **no document is created** — the draft is rolled back:

```json
{
  "error": "Placement failed: 3 field(s) could not be resolved.",
  "code": "placement_unresolved",
  "errors": [
    { "field": "{{sig:signer3}}", "page": 2, "reason": "role_unmapped", "hint": "Map signer3 in roles{} or remove the tag." },
    { "field": "fields[1]", "reason": "anchor_ambiguous", "candidates": ["occurrence 1 — page 1: \"Signature:\"", "occurrence 2 — page 4: \"Signature:\""] }
  ]
}
```

Extract the id for later steps with `jq`:

```bash
DOC_ID=$(curl -sS -X POST "$KOVASIGN_API_URL/documents" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Idempotency-Key: offer-8812" \
  -F "file=@offer-letter.pdf;type=application/pdf" \
  -F 'meta={"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};type=application/json' \
  | jq -r '.document.id')
```

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

From a public HTTPS URL (SSRF-checked) instead of an upload — JSON body, `source.url` in place of `file`:

```bash
curl -sS -X POST "$KOVASIGN_API_URL/documents" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Idempotency-Key: offer-8812-url" \
  -H "Content-Type: application/json" \
  --data '{
    "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
  }'
```

From a saved template — `template_id` in place of `file`/`source`. Only the roles you name are overridden; the rest keep the template's remembered name/email:

```bash
curl -sS -X POST "$KOVASIGN_API_URL/documents" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Idempotency-Key: offer-8812-tmpl" \
  -H "Content-Type: application/json" \
  --data '{
    "template_id": "tmpl_1",
    "name": "Offer — J. Tan",
    "roles": { "signer1": { "name": "Jane Tan", "email": "jane@x.com" } }
  }'
```

## 3. Sender signs first

Fetch the document to find the field whose `recipient_id` belongs to the sender (`hr@acme.com`, `r2`) and whose `field_type` is `"date"`:

```bash
curl -sS "$KOVASIGN_API_URL/documents/$DOC_ID" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  | jq '.fields[] | select(.recipient_id == "r2" and .field_type == "date")'
```

Sign the sender's own slot with a saved signature, attesting intent, and fill that date field in the same call:

```bash
curl -sS -X POST "$KOVASIGN_API_URL/documents/$DOC_ID/sign" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Content-Type: application/json" \
  --data '{
    "intent": {
      "user_confirmed": true,
      "confirmed_at": "2026-09-24T08:10:00Z",
      "surface": "acme-hris/v4",
      "actor_ref": "hr@acme.com"
    },
    "field_values": { "f_date_r2": "2026-09-24" }
  }'
```

A Singpass-required slot answers `409 singpass_required` — it can only be signed by that recipient via their own link.

## 4. Send

```bash
# One key per send ATTEMPT (store it with the body; reuse both on a retry).
# A re-send after a recall is a new attempt: new key.
SEND_ATTEMPT_KEY="$(uuidgen)"
curl -sS -X POST "$KOVASIGN_API_URL/documents/$DOC_ID/send" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Idempotency-Key: $SEND_ATTEMPT_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "attestation": {
      "user_confirmed": true,
      "confirmed_at": "2026-09-24T08:11:02Z",
      "surface": "acme-hris/v4"
    }
  }'
```

```json
{ "status": "pending", "first_step": 1, "total_recipients": 2, "sent_to": 1,
  "results": [{ "recipient": "jane@x.com", "success": true }] }
```

## 5. Poll for completion

```bash
until [ "$(curl -sS "$KOVASIGN_API_URL/documents/$DOC_ID" \
    -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
    -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" | jq -r '.document.status')" = "completed" ]; do
  sleep 5
done
echo "document $DOC_ID completed"
```

Polling works, but a webhook (§8 below) tells you the moment it happens instead of on a delay — see `typescript.md`/`python.md` for a receiver you can register against `document.completed`.

## 6. Download the signed PDF and certificate

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

```bash
curl -sS -D headers.txt -o signed.pdf "$KOVASIGN_API_URL/documents/$DOC_ID/files/signed" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF"

EXPECTED=$(grep -i '^x-content-sha256:' headers.txt | cut -d' ' -f2 | tr -d '\r')
ACTUAL=$(sha256sum signed.pdf | cut -d' ' -f1)
[ "$EXPECTED" = "$ACTUAL" ] && echo "sha256 verified" || echo "MISMATCH"
```

Or a short-lived URL instead of bytes:

```bash
curl -sS "$KOVASIGN_API_URL/documents/$DOC_ID/files/signed?disposition=url" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF"
# → { "url": "https://…", "expires_in": 3600, "sha256": "…" }
```

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

```bash
curl -sS -o certificate.pdf "$KOVASIGN_API_URL/documents/$DOC_ID/files/certificate" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF"
```

Both 409 `not_completed` until every signer has signed.

## 7. Templates and voiding

Save a completed (or in-progress) document as a reusable template:

```bash
curl -sS -X POST "$KOVASIGN_API_URL/documents/$DOC_ID/save-as-template" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Content-Type: application/json" \
  --data '{ "name": "Standard offer letter" }'
```

List templates and grab the new one's id:

```bash
curl -sS "$KOVASIGN_API_URL/templates?search=Standard%20offer" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  | jq '.templates[] | {id, name, roles}'
```

Create a new document from it, overriding one prefilled role (`signer1`) while the other keeps its remembered identity:

```bash
curl -sS -X POST "$KOVASIGN_API_URL/documents" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Idempotency-Key: offer-9001" \
  -H "Content-Type: application/json" \
  --data '{
    "template_id": "tmpl_2",
    "name": "Offer — K. Lim",
    "roles": { "signer1": { "name": "Kevin Lim", "email": "kevin@x.com" } }
  }'
```

Void a document that's no longer wanted (pending documents are recalled first, then archived — you receive a single `document.voided` event; completed ones keep their signed PDF/certificate downloadable):

```bash
curl -sS -X POST "$KOVASIGN_API_URL/documents/$DOC_ID/void" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: $KOVASIGN_ON_BEHALF_OF" \
  -H "Content-Type: application/json" \
  --data '{ "reason": "superseded by revised offer" }'
```

## 8. Webhooks — register, rotate, test, list deliveries

Webhook management is tenant-scoped: bearer token only, **no** `X-On-Behalf-Of`.

Register an endpoint (the plaintext secret is shown exactly once — save it):

```bash
curl -sS -X POST "$KOVASIGN_API_URL/webhooks" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "url": "https://acme.example.com/hooks/kovasign",
    "event_types": ["document.completed", "recipient.signed", "document.declined"],
    "label": "HRIS prod"
  }'
# → 201 { "webhook": { "id": "wh_1", ... }, "secret": "whsec_…" }
```

Rotate the secret (old secret keeps signing for 24h — deliveries during that window carry two `v1=` terms):

```bash
curl -sS -X POST "$KOVASIGN_API_URL/webhooks/wh_1/secret" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN"
```

Send a test ping (`Idempotency-Key` required — it has a real side effect):

```bash
curl -sS -X POST "$KOVASIGN_API_URL/webhooks/wh_1/test" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "Idempotency-Key: test-ping-1"
```

List recent deliveries:

```bash
curl -sS "$KOVASIGN_API_URL/webhooks/wh_1/deliveries?limit=25" \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  | jq '.deliveries[] | {event_type, sequence, state, last_status_code}'
```

For a runnable receiver that verifies `Kovasign-Signature` and applies events in order, see the TypeScript or Python snippets — both are stdlib-only and implement the same verification and dedupe rules described in `docs/runbooks/document-webhooks.md` §2 and §4.
