Document API reference

Create, place, send, track and download e-sign documents from your own code — the same engine the platform uses, over HTTPS.

Quickstart

Production

https://app.kovasign.com/api/v1

This is production. Build and test against the test environment (https://app.kovasignbeta.work/api/v1) first; it needs its own token from your test workspace.

  1. Get a token from your admin. A workspace admin creates it under Workspace → Developers → API & webhooks. It is shown once; keep it in your secret manager as KOVASIGN_API_TOKEN. The Connect your software card there hands over everything in one go as a .env block: KOVASIGN_API_URL, KOVASIGN_API_TOKEN, KOVASIGN_ON_BEHALF_OF and KOVASIGN_WEBHOOK_SECRET.
  2. Pick the member to act as. Every /documents call names a workspace member in X-On-Behalf-Of (see Authentication). If your company signs too, that member needs a saved signature in Kovasign.
  3. Put tags in your PDF. Type {{sig:signer1}}, {{date:signer1}} … where fields go, in white text (see Placement contract).
  4. Create, sign, send. POST /documents (multipart: file + meta with roles), then POST /documents/{id}/sign for your own slot, then POST /documents/{id}/send. Sign-then-send needs your role to have the lowest signing_order; signing a draft emails nobody, and send invites the first step that still has an unsigned signer. If you sign after the client, send first and sign when it is your turn.
  5. Receive the webhook. Register an HTTPS endpoint once — either your admin adds it in Connect your software (the secret arrives as KOVASIGN_WEBHOOK_SECRET) or you call POST /webhooks, never both. Verify Kovasign-Signature, dedupe on Kovasign-Event-Id and wait for document.completed (see Webhooks).
  6. Download. GET /documents/{id}/files/signed for the sealed PDF and /files/certificate for the Signing Log Certificate.

Build it with AI

Using Claude Code or another AI coding agent? Paste this prompt into it from your project. It tells the agent what to build, points it at the OpenAPI spec for this environment, and spells out the rules it must not get wrong: tokens from environment variables only, X-On-Behalf-Of, idempotency keys and webhook signature checks. Replace <member-email> with the member your integration acts as. Workspace admins get a copy with their own details filled in under Workspace → Developers.

Prompt for your AI coding agent
# Task: build a Kovasign Document API integration

You are an AI coding agent working in this codebase. Build a server-side integration with Kovasign.

## What Kovasign is
Kovasign is an e-signature platform. Its Document API lets a server create a signing request from a PDF, sign the company's own slot, send it to counterparties, and get a webhook when everyone has signed.

## The job
1. Create a document from a PDF, with signers mapped and fields placed, in ONE call.
2. Sign the company's own slot as the acting member (only if the company signs too).
3. Send it for signing.
4. React to webhooks: document.completed (also document.declined, document.expired, document.voided).
5. Download the sealed PDF and the Signing Log Certificate and store them against your own record.

## Environment
- Base URL (production): https://app.kovasign.com/api/v1
- Test and production are DIFFERENT hosts with DIFFERENT tokens; a token from one is refused (401) by the other. Read the base URL from KOVASIGN_API_URL; the UAT/test environment needs its own base URL and token.
- Configuration comes from environment variables: KOVASIGN_API_URL, KOVASIGN_API_TOKEN, KOVASIGN_ON_BEHALF_OF, KOVASIGN_WEBHOOK_SECRET. Your Kovasign admin gets all of them in one step from Workspace → Developers → Connect your software.
- Human reference: https://app.kovasign.com/help/developers/document-api
- OpenAPI 3.1 spec: https://app.kovasign.com/docs/openapi/document-api.yaml
  Fetch it first and treat it as the source of truth for every path, field and response shape. If this prompt and the spec disagree, the spec wins.

## Auth and required headers
- `Authorization: Bearer $KOVASIGN_API_TOKEN` on every call. Read the token from an environment variable or secret manager. NEVER hardcode, commit, log or print it, and never send it to a browser: all calls are server-side.
- `X-On-Behalf-Of: <member-email>` on every /documents* and /templates* call (not /webhooks*). It is the Kovasign member the call acts as. Read it from KOVASIGN_ON_BEHALF_OF.
- `Idempotency-Key` on every write; REQUIRED on POST /documents, POST /documents/{id}/send and POST /webhooks/{id}/test. One key = one ATTEMPT of one action, not one record:
  - generate a fresh key (e.g. a UUID) when you START an action and store it with the exact request body (including `confirmed_at`) before the first call;
  - on a timeout, 5xx or 409 `idempotency_in_progress`, retry with the SAME key and the byte-identical body: you get the original response back (`Idempotent-Replayed: true`) instead of a duplicate;
  - any NEW intent needs a NEW key: sending again after a recall, creating a replacement after a void, or a new approval. Reusing an old key replays the OLD response (nothing new happens) or, with a changed body, fails 422 `idempotency_key_reused`;
  - keys are per workspace and remembered for at least 24 hours.

## Create a document (one call)
POST https://app.kovasign.com/api/v1/documents as multipart/form-data with exactly two parts:
- `file`: the PDF
- `meta`: a JSON string, e.g. {"name":"Contract 123","roles":{"company":{"name":"Our signatory","email":"<member-email>","signing_order":1},"client":{"name":"Client Name","email":"[email protected]","signing_order":2}},"placement":{"tags":true},"expires_in_days":14}
Place fields with text tags inside the PDF: `{{type:role[:name][:flags]}}`
- type: sig | initials | date | text | checkbox | stamp
- examples: `{{sig:signer1}}`, `{{date:client}}`, `{{text:company:title}}`, `{{initials:client:optional}}` (the role is whatever key you used in `roles{}`)
- the role in a tag is a KEY of `roles{}` (letters, digits, _ and -; matched case-insensitively). That key is the only link between a tag and a person; an unmapped role fails.
- hide tags with white text; the PDF needs a text layer (scanned PDFs can't use tags).
If the PDF can't be edited, send `placement.fields[]` with `anchorText` (see the spec) instead of tags.
Placement is all-or-nothing: HTTP 422 `placement_unresolved` means NO document was created; `errors[]` lists each field with `reason` (and `candidates` for anchors). Surface these to a human; don't retry blindly. `placement.dry_run: true` validates without creating.

## Sign and send
- POST /documents/{id}/sign signs ONLY the acting member's own slot (the role whose email is the X-On-Behalf-Of member). Body: `{"intent":{"user_confirmed":true,"confirmed_at":"<ISO-8601>","surface":"<your-app>/<version>"}}`.
  The member must already have a saved signature in Kovasign, otherwise 409 `no_saved_signature`: show "add a saved signature in Kovasign" to the user. Singpass slots return 409 `singpass_required` (they sign via their own link).
  Any OTHER required field on the acting member's slot (date, text, checkbox) must be filled in the same call: add `"field_values":{"<field_id>":"<value>"}` (field ids from GET /documents/{id}). Otherwise 400 `missing_field_values` — its `missing[]` lists the ids. Signature fields are filled from the saved signature automatically. Simplest: give your own role only `{{sig:role}}` tags unless you really need a date.
- POST /documents/{id}/send with `{"attestation":{"user_confirmed":true,"confirmed_at":"<ISO-8601>","surface":"<your-app>/<version>"}}` plus an Idempotency-Key. It asserts that a real person in your system approved sending this exact document, so only call it after that approval.
- Order: if the company signs FIRST (its role has the lowest signing_order), call /sign on the draft, then /send. Signing a draft emails nobody; /send then invites the first step that still has an unsigned signer (never your already-signed slot). If the company signs AFTER the client, call /send first and /sign once the client has signed (before that /sign is refused as not your turn). A document whose only signer is the company completes on /sign and needs no /send.
- To stop a document, POST /documents/{id}/void (optional `reason`). Every signing link dies at once and the document can't be signed or sent again; to retry, create a NEW document (with a NEW Idempotency-Key).
- Never sign for a counterparty. They sign through the link Kovasign emails them.

## Who signs for the company
- GET /signatory (no X-On-Behalf-Of) returns `{company:{id,legal_name,short_name,registration_number}, signatory:{name,title,email,active,inactive_reason?,has_saved_signature,add_signature_url}}` from Workspace → Company. Read it at SEND time, never cache or hard-code it: an admin can change the signatory in Kovasign without you redeploying.
  - Several legal entities → 409 `company_required` with `companies[]`; pass `?company=<id or registration number>`. No signatory set → 404 `signatory_not_set`: tell the user to set one in Kovasign under Workspace → Company.
- ALWAYS build the document the same way: the signatory is the company's signer (signing_order 1, name = signatory.name), the counterparty second.
- If the person sending in your app IS the signatory: act as them (X-On-Behalf-Of = signatory.email) and TRY POST /documents/{id}/sign. If it 409s `no_saved_signature`, do NOT fail: just send. The signatory then gets a normal signing request first, and the signature they sign with is saved to their Kovasign account as their default, so the next self-sign succeeds. This try-then-send fallback is the recommended path; the popup below is optional polish.
- Otherwise: put the signatory as the FIRST signer (signing_order 1, a normal Kovasign signing request they get by email), the counterparty second, and send WITHOUT self-signing. Act as the sender if they are an active member (check readiness), else as the signatory.
- `signatory.active: false` with `inactive_reason: "not_a_member"` means the configured signatory isn't in the Kovasign workspace yet: surface "ask your Kovasign admin to invite <email>" instead of failing later.

## Before self-signing: check signing readiness
- GET /members/{email}/signing-readiness (no X-On-Behalf-Of; add `?from=<your app name>`) → `{email,active,inactive_reason?,has_saved_signature,add_signature_url}`. A non-member is 404 `actor_not_found`.
- `active: false` → don't call /sign (it would 403 `actor_not_active`); show `inactive_reason` (deactivated / suspended).
- `has_saved_signature: false` → don't call /sign yet (it would 409 `no_saved_signature`). From a USER CLICK (popup blockers), open `add_signature_url` with `window.open(url, "kovasign-signature", "popup,width=720,height=820")`. Kovasign can't be embedded in an iframe, so always use a popup. The member signs in if needed (Google sign-in works in the popup), the Add Signature dialog opens by itself, and after saving the popup posts `{type:"kovasign:signature-saved"}` to `window.opener` and closes itself; closing the dialog without saving posts `{type:"kovasign:signature-cancelled"}`.
- Listen with `window.addEventListener("message", e => …)`, accept only messages whose `e.source` is the popup you opened (the messages carry no data, so there is nothing else to verify), then re-check readiness and retry the sign. Treat a popup closed without any message as cancelled.

## Errors
- Every error body is `{"error":"<human text>","code":"<stable code>","request_id":"…"}`. Branch on `code`, never on `error`.
- Log the `Kovasign-Request-Id` response header next to your own ids.
- 429 `rate_limited`: wait `Retry-After` seconds. 5xx: retry with backoff, the SAME Idempotency-Key and the byte-identical body. 409 `idempotency_in_progress`: retry shortly, same key and body.

## Webhooks
- Register your receiver's public HTTPS URL ONCE: either your Kovasign admin adds it (Workspace → Developers → Connect your software) and hands you the secret, or you register it yourself (your token is allowed to) — never both. First GET /webhooks: if an endpoint for your URL already exists, don't create another.
  POST /webhooks `{"url":"https://…","event_types":["document.completed","document.declined","document.expired","document.voided"]}` (send an Idempotency-Key so a retried call can't create two). The response contains the signing secret ONCE; store it as KOVASIGN_WEBHOOK_SECRET.
- Verify EVERY delivery before trusting it:
  - header `Kovasign-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`
  - expected = lowercase hex of HMAC-SHA256(key = the secret string exactly as issued, including its `whsec_` prefix, as UTF-8; message = `<t>` + "." + <raw request body>)
  - use the RAW body bytes as received; never parse and re-serialise the JSON first
  - reject if |now - t| > 300 seconds
  - accept if ANY v1 value equals expected (during a secret rotation there are two); compare in constant time
  - if the check fails, answer HTTP 401 and do nothing else
- Dedupe on the `Kovasign-Event-Id` header (same as the body's `id`). Delivery is at-least-once, and replays reuse the same id with `Kovasign-Replay: true`.
- Ordering isn't guaranteed. Per document, apply an event only if its `sequence` is higher than the last one you applied.
- Reply 2xx within 10 seconds and do the real work asynchronously. A 4xx other than 408/429 is NOT retried; 5xx and timeouts are retried for about 33 hours; 410 disables the endpoint.
- Payloads carry no names, emails or PDFs. Call GET /documents/{id} for details.

## Download
After document.completed: GET /documents/{id}/files/signed (sealed PDF) and GET /documents/{id}/files/certificate. They return the PDF bytes; add `?disposition=url` for a 1-hour download link instead. 409 `not_completed` = not everyone has signed; 409 `file_not_ready` = retry shortly. Check the bytes against the `X-Content-SHA256` response header.

## Testing
- Build and test against the UAT/test environment first, with a UAT token. Production needs a separate token from the production workspace admin.
- Use POST /webhooks/{id}/test (with an Idempotency-Key) to send a synthetic `document.test` event to your receiver.
- Add unit tests for the signature check: valid, tampered body, stale timestamp, and two v1 values.

## Do not
- Do not use Kovasign's MCP connector (the AI-assistant connector) for this. It is for a person chatting with an assistant, not for server-to-server integrations. Use this REST API.
- Do not put the API token or webhook secret in client code, logs, error messages or source control.

## Deliverables
A small typed client module, the create → sign → send flow, the webhook route with signature verification and dedupe, configuration through environment variables (KOVASIGN_API_URL, KOVASIGN_API_TOKEN, KOVASIGN_ON_BEHALF_OF, KOVASIGN_WEBHOOK_SECRET), and tests.

Overview

The Document API is headless parity with the Kovasign platform: whatever you can do by clicking through the app, your own code can drive over plain HTTPS — create a document, place signature fields, add recipients, send it, sign your own slot, track progress, download the signed PDF and certificate, save and reuse templates, and void a document you no longer need. Nothing is exposed here that the platform doesn't already do; the API is a second entry point into the same engine, not a parallel product.

The Document API is available on the Enterprise plan. All routes are under a single base URL — shown here for the environment you are reading this on (Production):

Production
https://app.kovasign.com/api/v1

The test environment (app.kovasignbeta.work) and production (app.kovasign.com) are separate systems: separate workspaces, separate data and separate tokens. A token minted in one is refused with 401 by the other, so keep the base URL and token together in your configuration.

Every request and response body is JSON, except document creation (which also acceptsmultipart/form-data for a file upload) and file downloads (which return raw bytes by default — see Endpoints).

Tokens are minted from your workspace: Workspace → Developers → API & webhooks. An admin or owner mints a token there; the plaintext is shown once, so store it in your own secret manager immediately. The request log and webhook delivery log described below are next door, under Workspace → Developers → Logs.

An OpenAPI 3.1 description of this API is at /docs/openapi/document-api.yaml (generated from the same validation schemas the API runs — feed it to your client generator). The full worked example — create with tags, sign, send, poll or receive the webhook, download, templates, void — is available in three languages:

  • curl — plain curl + jq, plus webhook endpoint management
  • TypeScript — Node 18+ fetch, no dependencies, with a signature-verifying receiver
  • Python — requests + stdlib, with a signature-verifying receiver

Authentication

Every request carries a bearer token minted for your tenant:

http
Authorization: Bearer kvs_doc_…

A tenant may hold up to 20 live tokens at once — mint one per integration rather than sharing a single token across systems. Rotating a token keeps the old value valid for a 24-hour grace window so you can roll credentials without downtime.

Acting on behalf of a member — X-On-Behalf-Of

The token authenticates your tenant; it does not identify a person. Every call to/documents* and /templates* (not /webhooks*, which is tenant-scoped) must also name the tenant member the call is acting for:

http
X-On-Behalf-Of: [email protected]

The value is a member's email address or profile UUID. The document (or template) created by the call is owned by that member — exactly as if they had signed in and clicked through the app themselves. A JSON body may also carry the alias field on_behalf_of; the header wins if both are present.

Refusals are deterministic:

ConditionStatuscode
Header missing400actor_required
No member matches the email/UUID404actor_not_found
Member is not active — see reason below403actor_not_active

actor_not_active carries a reason of not_a_member, deactivated, or suspended, so your integration can distinguish "this person left the company" from "this seat is paused" without an extra lookup.

Idempotency

Writes that create side effects accept an Idempotency-Key header (up to 128 characters), and it is required on the three calls where retrying blind would be unsafe:

  • POST /documents
  • POST /documents/{id}/send
  • POST /webhooks/{id}/test

It is optional everywhere else. Semantics:

SituationResult
Same key, same request body, called againThe original response is replayed with header Idempotent-Replayed: true
Same key, a different request body422 idempotency_key_reused
Same key, a request is still in flight409 idempotency_in_progress
Required but missing400 idempotency_key_required
Over 128 characters400 idempotency_key_invalid

Choosing keys. A key identifies one attempt of one action, not a record. Generate a fresh key (a UUID is fine) when you start the action and store it together with the exact request body — including confirmed_at — before the first call. Retry a timeout, 5xx or idempotency_in_progress with the same key and the byte-identical body. Any new intent needs a new key: sending again after a recall, creating a replacement after a void, or a fresh approval. Reusing an old key replays the old response (nothing new happens) or, if the body changed, fails with idempotency_key_reused. Keys are scoped to your workspace and remembered for at least 24 hours.

Rate limits

300 requests/minute per tenant and 120 requests/minute per token. Exceeding either returns 429 rate_limited with a Retry-After header — back off for that many seconds before retrying.

Request ids and the developer logs

Every response carries a Kovasign-Request-Id header, and every error envelope repeats it as request_id. Log it next to your own correlation id and quote it when you contact support — it is the key we use to find the exact call.

http
HTTP/1.1 422 Unprocessable Entity
Kovasign-Request-Id: k7Qm2xP9aB1c

{"error": "…", "code": "placement_unresolved", "request_id": "k7Qm2xP9aB1c", "errors": […]}

You rarely need support for that, though: Workspace → Developers → Logs shows every call your tokens made — success or failure, including 4xx, 429 and idempotent replays — with its status, duration, error body and request id, kept for 90 days and filterable by token, member, status class, endpoint and time. Pasting a request id into the log opens that call. The same page has, for webhooks, the full delivery log with a Replay button, and the API & webhooks page lists each token's health (calls, error rate, median latency, which members it acted for) (see Webhooks). Calls that fail before a token is recognised — an unknown token, or the per-token limit tripping — cannot be attributed to your workspace and are not listed.

Placement contract

Placement tags

Put a tag anywhere in your PDF's text layer where a field should appear. The field's box is placed on top of the tag (top-left corner of the box = top-left of the tag text).

ebnf
tag      ::= "{{" type ":" role [ ":" name ] [ ":" flags ] "}}"
type     ::= "sig" | "initials" | "date" | "text" | "checkbox" | "stamp"
role     ::= "signer" digit+ | "viewer" digit+ | ident            ; e.g. signer1, landlord
name     ::= ident                                                ; distinguishes several fields of one type for one role
flags    ::= flag ( "," flag )*
flag     ::= "optional" | "w=" number | "h=" number | "fmt=" datefmt | "singpass"
ident    ::= [A-Za-z][A-Za-z0-9_-]*
datefmt  ::= "MMMM D, YYYY" | "MM/DD/YYYY" | "DD/MM/YYYY" | "YYYY-MM-DD" | "D MMMM YYYY"

Examples: {{sig:signer1}}, {{date:signer1}}, {{text:signer2:title}}, {{initials:signer1:optional}}, {{sig:signer1:w=25,h=6}}, {{date:signer2:fmt=DD/MM/YYYY}}, {{sig:signer1:singpass}}. Tags are case-insensitive; whitespace inside braces is ignored.

TypeKovasign fieldDefault box (% of page W × H)
sigsignature15 × 5
initialsinitials8 × 4
datedate (auto-filled at signing)20 × 3
texttext20 × 3
checkboxcheckbox4 × 4
stampcompany stamp10 × 10

Defaults are scaled to the real page size, so a box is the same physical size on A4, Letter and landscape pages. w=/h= override in percent. Every optional-less field is required, matching the editor's own default.

Roles are abstract. signer1, landlord, … are labels you map to real people in the API call (roles: {signer1: {name, email}}). A tag whose role is not mapped fails validation (see below). viewerN roles receive the document but own no fields; a tag on a viewer role is an error — the same rule the editor enforces.

Hiding the tag

Set the tag's text colour to the page background (white on white). Kovasign matches on the text layer, which ignores colour. Do not use 1-pt fonts or off-page text: the match still works, but the box is then sized from your override/defaults rather than the glyphs, and some PDF exporters drop sub-2-pt runs entirely. The tag remains in the PDF under the field box; the signed output covers it with the field content.

Text-layer requirement

Tags and anchors both need a text layer. PDFs exported from Word, Google Docs or design tools keep one. Scanned or image-only PDFs cannot use tags or anchors — the validator reports no_text_layer; use explicit coordinates or a template instead. Limits: 200 pages, 20 MB, for text extraction.

Anchor rules (fallback #1)

Place relative to existing words when you cannot edit the PDF:

json
{ "role": "signer1", "type": "sig", "anchorText": "Authorised Signatory",
  "occurrence": 2, "page": 3, "position": "below", "width": 20, "height": 6 }

Matching is a case- and whitespace-insensitive substring match over text lines. position is one of on | below | right | above (default on); occurrence is 1-based in reading order; page restricts the search to one page. Ambiguity — more than one match without an occurrence — is an error with every candidate listed, never a guess. Discover real anchor text with GET /documents/{id}/text?query=.

Explicit coordinates (fallback #2)

page is 1-based; x, y, width, height are percentages (0–100) of the page's width and height with the origin at the TOP-LEFT corner — x is the box's left edge, y is the box's top edge.

(0,0) ┌────────────────────────────┐ (100,0)
      │                            │
      │    x=10,y=20 ┌──────┐      │   width=15 → box spans x 10..25
      │              │ sig  │      │   height=5 → box spans y 20..25
      │              └──────┘      │
      │                            │
(0,100)└────────────────────────────┘ (100,100)

Boxes are clamped inside the page (x ≤ 100-width, y ≤ 100-height) and rounded to 2 decimal places.

Deterministic validation and the error contract

Placement is all-or-nothing: either every tag, anchor and coordinate resolves and every role maps to a recipient, or nothing is written and you get 422 listing every problem:

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:\""] },
    { "field": "fields[2]", "reason": "anchor_not_found",
      "candidates": ["page 4: \"Authorised Signatory\"", "page 4: \"Date\""] },
    { "field": "{{sig:signer1:singpass}}", "page": 1, "reason": "singpass_off_page" }
  ] }

reason is one of: tag_malformed, role_unmapped, role_is_viewer, unknown_type, anchor_not_found, anchor_ambiguous, occurrence_out_of_range, page_out_of_range, no_text_layer, coords_out_of_range, singpass_overlap, singpass_off_page, singpass_wrong_type.

Warnings are non-fatal and returned alongside a successful placement, separately from errors: warnings: [{field, code: "tag_visible" | "duplicate_tag"}].

Singpass overlay constraints

A singpass field must be of type sig. Singpass stamps a fixed 221 × 61 pt block whose top-left is your box's top-left, regardless of any w=/h= override. At placement time the API runs the same validation the editor and the send step run — over the union of every Singpass field on the document, against the real page dimensions — and reports singpass_off_page or singpass_overlap per field. Send re-checks before dispatch, so nothing can slip through between placement and sending. A resolved Singpass field is written with requires_singpass=true.

Guarantees

  • One origin, one unit, one anchor. Every coordinate in this contract — for tags, anchors and explicit coordinates alike — uses the same system: origin at the top-left, units in percent, anchor at the top-left corner of the box. Nothing here switches to a different origin, unit or anchor partway through.
  • Every default is printed. Every field type's default box size and the Singpass overlay's footprint are documented above — nothing is left for you to discover empirically.
  • Every rejection names the field, the reason and the candidates. A 422 tells you exactly which field failed, why (the reason enum), and — for anchors — what it did find, so you can fix the call instead of guessing.
  • No hidden rounding — what you get back is what was written. Coordinates are rounded to 2 decimal places and the rounded values are exactly what's returned in placed[]; nothing is silently clamped without appearing there.

Endpoints

All paths are relative to the base URL and require the bearer token; /documents* and /templates* also require X-On-Behalf-Of.

Documents

Method + pathPurposeIdempotency-KeyNotable errors
POST /documentsCreate a document — from an uploaded file, a URL, or a template — optionally mapping roles and placing fields in the same callRequiredvalidation_error, placement_unresolved (no document is created), payload_too_large, source_fetch_failed
GET /documentsList documents (cursor-paginated)—invalid_cursor
GET /documents/{id}Get one document, its recipients and its field geometry—not_found
GET /documents/{id}/textRead the text layer, for anchor discovery—not_found
POST /documents/{id}/recipientsAdd a recipient to a draftOptionalvalidation_error, not_found
POST /documents/{id}/fieldsPlace fields on a draft, via tags, anchors or coordinatesOptionalplacement_unresolved
POST /documents/{id}/sendSend the document for signingRequiredattestation_required, already_sent, billing_past_due, seat_suspended
POST /documents/{id}/signSign the acting member's own slotOptionalintent_required, not_your_slot, singpass_required, no_saved_signature, already_signed
POST /documents/{id}/remindNudge one recipientOptionalrate_limited (3 per recipient per 24h)
POST /documents/{id}/recallPull a sent document back to draftOptionalnot_recallable
POST /documents/{id}/voidRecall (if needed) and archive — see VoidOptionalnot_found, conflict
POST /documents/{id}/save-as-templateSave as a reusable templateOptionalforbidden (plan cap), conflict (no PDF to copy)
GET /documents/{id}/files/signedDownload the signed PDF—not_completed
GET /documents/{id}/files/certificateDownload the Signing Log Certificate—not_completed
GET /documents/{id}/auditRead the ordered audit trail—not_found

File downloads default to raw bytes (Content-Disposition: attachment + X-Content-SHA256) — the same thing a click in the app produces. Pass ?disposition=url for a fresh signed URL (600s TTL) instead of proxying the bytes yourself.

Templates

Method + pathPurposeIdempotency-KeyNotable errors
GET /templatesList templates visible to the acting member (own ∪ team-shared)—invalid_cursor
GET /templates/{id}Get one template's roles and field geometry—not_found

Webhooks

Tenant-scoped: bearer token only, no X-On-Behalf-Of — an endpoint belongs to the workspace, not to a member.

Method + pathPurposeIdempotency-KeyNotable errors
GET /webhooksList this tenant's endpoints——
POST /webhooksRegister an endpoint — mints and returns the secret onceOptionalwebhook_cap_reached, invalid_url, insecure_scheme, ssrf_blocked
GET /webhooks/{id}Get one endpoint—not_found
PATCH /webhooks/{id}Update URL, filter, label or enabled stateOptionalsecret_required
DELETE /webhooks/{id}Remove an endpointOptionalnot_found
POST /webhooks/{id}/secretRotate the signing secretOptionalnot_found
POST /webhooks/{id}/testSend a synthetic document.test pingRequirednot_found
GET /webhooks/{id}/deliveriesList delivery attempts (cursor-paginated)—invalid_cursor

Signatory and signing readiness

Tenant-scoped: bearer token only, no X-On-Behalf-Of. See Who signs for the company and Signing readiness.

Method + pathPurposeIdempotency-KeyNotable errors
GET /signatoryThe company's default signatory (Workspace → Company) plus their signing readiness. ?company= = entity id or registration number—company_required, company_not_found, signatory_not_set
GET /members/{email}/signing-readinessCan this member self-sign now? Active seat + default saved signature + the popup link to add one—actor_not_found

Webhooks

Register up to three HTTPS endpoints per tenant, each with an optional event_types filter (empty or absent = every event). Every document and recipient lifecycle transition is delivered to every subscribed, enabled endpoint within roughly 1–2 seconds, signed and retried on a backoff ladder.

Events

typeWhenrecipient_idExtra data keys
document.sentThe document left draft for pending——
recipient.sentA signer's link was issued — every step-1 signer at send, then each signer as a sequential cascade reaches them✓—
recipient.viewedA signer opened their link for the first time✓—
recipient.signedOne signer completed their signature✓—
recipient.declinedA signer refused✓reason
document.completedThe last signer landed; emitted once per document——
document.declinedThe document stopped because a signer declined✓ (the decliner)reason
document.expiredExpiry passed with signers still outstanding——
document.recalledThe sender pulled it back to draft; links died——
document.voidedThe document was voided — see Void—previous_status, reason
document.testSynthetic — only from POST /webhooks/{id}/test; sequence 0, nil document_id——

Envelope

Compact JSON with keys in a fixed order — the signature covers the raw bytes, so serialisation is part of the contract:

json
{"id":"evt_3f9a…","type":"recipient.signed","api_version":"2026-09-24","tenant_id":"<uuid>","document_id":"<uuid>","recipient_id":"<uuid>","actor":{"user_id":"<uuid>","via":"api"},"occurred_at":"2026-09-24T08:15:02.113Z","sequence":4182,"data":{"status":"pending","progress":{"signed_count":1,"total_signers":2}}}

recipient_id and actor are omitted (not null) when there isn't one. actor.via is one of web | api | mcp | system | signer — present only when a person with a Kovasign account caused the transition; a public-link signer has none. data.status is the document status after the transition; data.progress is logical-signer progress. The payload is data-minimal — no names, no emails, no PDF bytes. Fetch the rest with GET /documents/{id}.

Headers

http
Content-Type: application/json
User-Agent: Kovasign-Webhooks/1
Kovasign-Event-Id: evt_…
Kovasign-Delivery-Attempt: 1
Kovasign-Signature: t=1758700502,v1=<hex>[,v1=<hex with the previous secret>]
Kovasign-Replay: true            # only on a replayed delivery — see Replaying a delivery

Verifying a delivery

  1. Build signed = `${t}.${rawBody}` from the timestamp in the header and the raw request body — never re-serialise the parsed JSON.
  2. Compute v1 = hex(HMAC-SHA256(secret, signed)).
  3. Reject the delivery if |now − t| > 300s.
  4. Accept if any v1 term in the header matches any secret you currently hold (both the current and, during a rotation, the previous secret).
  5. Compare in constant time.
verify-webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";

/**
 * Verify a Kovasign webhook delivery. Pass the RAW request body exactly as
 * received (never re-serialised JSON) and every secret you currently hold
 * for this endpoint — the current one, plus the previous one during a
 * 24-hour rotation window.
 */
export function verifyKovasignWebhook(
  rawBody: string,
  signatureHeader: string,
  secrets: string[]
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=") as [string, string])
  );
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // stale — reject

  const signed = `${t}.${rawBody}`;
  const candidates = signatureHeader
    .split(",")
    .filter((kv) => kv.startsWith("v1="))
    .map((kv) => kv.slice(3));

  return secrets.some((secret) => {
    const expected = createHmac("sha256", secret).update(signed).digest("hex");
    return candidates.some(
      (v1) =>
        v1.length === expected.length &&
        timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
    );
  });
}

Dedupe and ordering

Delivery is at-least-once. Dedupe on event_id (insert with ON CONFLICT DO NOTHING; a duplicate acks 200 without reapplying). Ordering is not guaranteed across attempts — an event that timed out and retried can land after a later one that succeeded first. Store the last-applied sequence per document and apply an event only when event.sequence > last_applied; otherwise ack 200 and discard. Always compare on sequence, never on occurred_at.

Retry ladder

7 attempts over roughly 33 hours, exponential with jitter:

AttemptDelay after previousElapsed
1— (delivered within ~1–2s; 60s backstop)~0
21 min~1 min
35 min~6 min
430 min~36 min
52 h~2.6 h
66 h~8.6 h
724 h~33 h

Response handling

Your responseMeaningAction
2xxDeliveredDone.
410 GoneEndpoint retiredStops retrying this event and disables that endpoint (disabled_reason: "endpoint_gone"); other endpoints are untouched.
Other 4xxYour handler rejected itPermanent failure for this event — no retry.
408, 429TransientRetry, honouring Retry-After when present (clamped to ≤24h).
5xxYour side brokeRetry.
Timeout (10s) / DNS / TLS / connection refusedTransientRetry.
3xxRedirectTreated as failure — never followed.

A permanent 4xx means a deploy that briefly 401s your webhook route drops every event in that window — poll GET /documents/{id} for anything you can't reconcile, and use GET /webhooks/{id}/deliveries to see exactly which events failed.

Replaying a delivery

A delivery in delivered, failed or dead can be re-sent — from Workspace → Developers → Logs → Webhook deliveries, or from code with POST /webhooks/{id}/deliveries/{deliveryId}/replay. A replay is a re-send, not a new event: your endpoint receives the same Kovasign-Event-Id and the same event body (re-serialised from the stored payload, signed with your current secret), with Kovasign-Replay: true added and Kovasign-Delivery-Attempt continuing from where the original attempts stopped. Dedupe on the event id exactly as you already do — a replayed event you have applied should ack 200 and be discarded; one you lost after acking 200 gets applied now. A replayed dead delivery gets one more attempt; replay again for another.

Replay refusedStatuscode
The delivery is already queued409already_pending
The endpoint is switched off409endpoint_disabled

Secret rotation

POST /webhooks/{id}/secret rotates the signing secret. The previous secret keeps signing for a 24-hour grace window, during which every delivery carries two v1= terms — accept if either matches a secret you hold, so you can update your verifier without downtime.

Self-signing and attestation

The API never signs on a counterparty's behalf, and it never sends without a recorded assertion that a real person approved the action in your system.

Sending

POST /documents/{id}/send requires an attestation object — {user_confirmed: true, confirmed_at, surface, actor_ref?} — asserting that the named on_behalf_of member approved dispatch of this exact document in your system. Missing it is a 400 before anything else runs. It is recorded on the send's audit row as {via: "api", api_token_id, attestation} and printed on the Signing Log Certificate as:

"Sent via Kovasign Document API on behalf of <actor> (attested by <surface>)"

Self-signing

POST /documents/{id}/sign lets the acting member sign their own slot with their own saved Kovasign signature — the same thing clicking "Sign" in the app does, called from your system instead. Rules, enforced identically to the in-app path:

  • The actor may sign only a recipient slot whose email or account is their own — never a counterparty's. Counterparties always sign via their own emailed or WhatsApp link.
  • A saved signature (from user_signatures) must already exist. Nothing is synthesised on the fly.
  • An intent object is required — {user_confirmed: true, confirmed_at, surface, actor_ref?} — recorded as {via: "api", api_token_id, intent_confirmed_at, attestation}.
  • ip_address on this signature is written as NULL — never your server's IP masquerading as the signer's.
  • Text, date and checkbox fields on the actor's own slot may be filled in the same call via field_values.

The certificate line for a self-signed slot reads:

"Signed via Kovasign Document API on behalf of <actor> (attested by <surface>)"

A slot requiring Singpass cannot be signed this way — Singpass identity verification is not headless. The call returns 409 singpass_required; that recipient signs through their own signing link as usual.

Who signs for the company — GET /signatory

Workspace → Company → Legal entity is the single source of truth for who signs on the company's behalf. Read it at send time — an admin can change the signatory in Kovasign without you redeploying. With several legal entities, pass ?company=<id or registration number> (otherwise 409 company_required lists them).

200 GET /signatory
{
  "company": { "id": "…", "legal_name": "Acme Pte. Ltd.", "short_name": "Acme", "registration_number": "201912345A" },
  "signatory": {
    "name": "Jane Tan", "title": "Director", "email": "[email protected]",
    "active": true,
    "has_saved_signature": false,
    "add_signature_url": "https://app.kovasign.com/esign/signatures?add=1&from=Loom"
  }
}
  • The person sending in your app IS the signatory → act as them (X-On-Behalf-Of), put them in the company's signer slot with signatory.name, self-sign, then send.
  • Anyone else → put the signatory as the first signer (a normal Kovasign signing request), the counterparty second, and send without self-signing. Act as the sender if they're an active member, else as the signatory.
  • The signatory email is free text: if it isn't a member of this workspace you get active: false, inactive_reason: "not_a_member" (200, not 404) — ask the admin to invite them.

Signing readiness and the add-signature popup

Before POST /documents/{id}/sign, call GET /members/{email}/signing-readiness?from=<your app name>. It returns {email, active, inactive_reason?, has_saved_signature, add_signature_url}: active is the same check as X-On-Behalf-Of, and has_saved_signature is the same default-signature check the sign route uses. Someone who isn't a member of your workspace is a 404 actor_not_found.

No saved signature? Kovasign can't be embedded in an iframe, so open add_signature_url in a popup from a user click. The member signs in if needed (Google sign-in works there), the Add Signature dialog opens by itself, and the popup tells its opener what happened with postMessage: {type: "kovasign:signature-saved"} (then it closes itself) or {type: "kovasign:signature-cancelled"}. The messages carry no data — just check they came from your popup.

Browser — open the popup and wait
// From a click handler (popup blockers allow window.open only there).
const popup = window.open(readiness.add_signature_url, "kovasign-signature",
  "popup,width=720,height=820")

window.addEventListener("message", function onMessage(e) {
  if (e.source !== popup) return            // only trust OUR popup
  if (e.data?.type === "kovasign:signature-saved") {
    window.removeEventListener("message", onMessage)
    retrySign()                             // re-check readiness, then POST …/sign
  } else if (e.data?.type === "kovasign:signature-cancelled") {
    showHint("No signature saved yet.")
  }
})

Templates

POST /documents/{id}/save-as-template copies the PDF and every field's geometry, and creates one role per recipient. Each role remembers the recipient's name and email, so a document created from the template later arrives with those recipients already pre-filled — override any of them per instance via roles{} on create. The template is personal to the member who saved it.

GET /templates lists templates visible to the acting member (their own and any team-shared ones); GET /templates/{id} returns one template's roles and field geometry in full.

To start a new document from a template, pass {template_id} to POST /documents. Keys in roles{} are matched against the template's roles by role_key or role_label (case-insensitive); a matched role's recipient is filled or overwritten with the identity you supply, while any template role you don't name keeps its remembered name and email (or stays a placeholder). Every recipient materialised from a template is stamped with api_role equal to its role key, so later calls — and the tags in the copied fields — can keep naming it.

Void

POST /documents/{id}/void is the only way to void a document over the API — the dashboard keeps its own separate recall/delete actions. Body: {reason?: string ≤ 500 chars}.

Semantics depend on the document's current status:

  • Pending or expired — recalled first (every signing link dies), then archived. You receive a single document.voided event with data.previous_status set to pending; no separate document.recalled is sent for a void.
  • Draft — archived directly.
  • Completed or declined — archived only; the signed PDF and certificate remain downloadable exactly as before.

An archived document disappears from every list — both the API and the dashboard — unless you pass include_archived=true to GET /documents. It remains readable directly by GET /documents/{id}. Voiding emits a document.voided webhook and writes an audit row (action: "voided", with via, the token id, the reason and the previous status). Calling void again on an already-voided document is a no-op that returns already_voided: true rather than an error.

Errors

Every error is {error: <human-readable>, code: <stable>}. Two codes carry extra structure: validation_error adds issues: [{path, message}], and placement_unresolved adds errors: [{field, page?, reason, hint?, candidates?}] (see Placement contract). Every successful response carries Cache-Control: no-store.

codeStatusMeaning
unauthorized401Missing, malformed or unrecognised bearer token
token_expired401Token past its rotation grace window
rate_limited429Tenant or token rate limit exceeded — see Retry-After
enterprise_required403Tenant is not on the Enterprise plan
actor_required400X-On-Behalf-Of missing
actor_not_found404No member matches the email/UUID given
actor_not_active403Member exists but can't act — see reason
idempotency_key_required400Idempotency-Key required but missing
idempotency_key_invalid400Key over 128 characters
idempotency_key_reused422Same key, different request body
idempotency_in_progress409Same key, an earlier call is still in flight
validation_error400Request body failed schema validation — see issues[]
invalid_cursor400Pagination cursor is malformed or stale
payload_too_large413Uploaded file over 25MB
source_fetch_failed400Fetching a source.url document failed — see its reason
webhook_cap_reached400Tenant already has 3 webhook endpoints
secret_required400Enabling an endpoint with no secret set
invalid_url400Webhook URL isn't a well-formed HTTPS URL
insecure_scheme400Webhook or source URL isn't https://
ssrf_blocked400URL resolves to a private/internal address
dns_error400URL's host doesn't resolve
company_required409GET /signatory: several legal entities — pass ?company=; see companies[]
company_not_found404GET /signatory: ?company= matches none of the workspace's legal entities
signatory_not_set404GET /signatory: no legal entity, or no signatory email on it — set it under Workspace → Company
placement_unresolved422One or more fields didn't resolve — see errors[] and Placement contract
seat_suspended403The acting member's seat is suspended
billing_past_due402Tenant's billing is past due — sending is blocked
already_sent409Document is not a draft
not_recallable409Document already completed or declined
not_found404Resource doesn't exist or isn't visible to the actor
forbidden403Action not permitted (e.g. plan cap)
conflict409Action doesn't apply to the resource's current state
internal_error500Unexpected server error — safe to retry
attestation_required400Send called without an attestation object
intent_required400Self-sign called without an intent object
not_your_slot403Actor tried to sign a recipient slot that isn't theirs
singpass_required409That slot requires Singpass — not headless-signable
no_saved_signature409Actor has no saved signature to sign with. Recommended: just send — they get a normal signing request first, and the signature they use becomes their saved default, so the next self-sign works. Or check signing readiness first
already_signed409That slot is already signed
not_your_turn409Sequential signing order hasn't reached this slot yet
document_voided409The document was voided: it can't be signed or sent again. Create a new document (new Idempotency-Key)
not_completed409Requested a signed file before every signer finished
file_not_ready409Sealed PDF still being produced — retry shortly
invalid_created_after400Malformed created_after list filter

Snippets

Three requests from the same worked flow — create with tags, send with attestation, then poll and download. The full end-to-end example in each language: curl, TypeScript, Python.

Create — curl

create.sh
curl -X POST https://app.kovasign.com/api/v1/documents \
  -H "Authorization: Bearer $KOVASIGN_API_TOKEN" \
  -H "X-On-Behalf-Of: [email protected]" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "[email protected]" \
  -F 'meta={
    "name": "Offer — J. Tan",
    "roles": {
      "signer1": { "name": "Jane Tan", "email": "[email protected]", "signing_order": 2 },
      "signer2": { "name": "Acme HR", "email": "[email protected]", "signing_order": 1 }
    },
    "placement": { "tags": true },
    "expires_in_days": 7
  }'

Send with attestation — TypeScript

send.ts
// One attempt = one key + one exact body, saved BEFORE the first call so a
// retry after a timeout sends the same bytes (a new confirmed_at under the
// same key would 422 idempotency_key_reused). A re-send after a recall is a
// NEW attempt: new key, new approval.
const attempt = await db.sendAttempts.findOrCreate(documentId, () => ({
  key: crypto.randomUUID(),
  body: JSON.stringify({
    attestation: {
      user_confirmed: true,
      confirmed_at: approval.approvedAt, // when the human approved in your system
      surface: "acme-hris/v4",
    },
  }),
}));

const res = await fetch(`https://app.kovasign.com/api/v1/documents/${documentId}/send`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KOVASIGN_API_TOKEN}`,
    "X-On-Behalf-Of": "[email protected]",
    "Idempotency-Key": attempt.key,
    "Content-Type": "application/json",
  },
  body: attempt.body,
});

const body = await res.json();
if (!res.ok) {
  throw new Error(`send failed: ${body.code} — ${body.error}`);
}
// body: { status: "pending", first_step: 1, results: [...] }

Poll and download — Python

poll_and_download.py
import os
import time
import requests

BASE = os.environ.get("KOVASIGN_API_URL", "https://app.kovasign.com/api/v1")
HEADERS = {
    "Authorization": f"Bearer {os.environ['KOVASIGN_API_TOKEN']}",
    "X-On-Behalf-Of": "[email protected]",
}

def wait_for_completion(document_id: str, timeout_s: int = 3600, interval_s: int = 15) -> dict:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        doc = requests.get(f"{BASE}/documents/{document_id}", headers=HEADERS).json()["document"]
        if doc["status"] == "completed":
            return doc
        # There is no "recalled"/"voided" status: a recall returns the
        # document to "draft", and a void also sets archived_at.
        if doc.get("archived_at") or doc["status"] in ("declined", "expired", "draft"):
            raise RuntimeError(f"document did not complete: {doc['status']}")
        time.sleep(interval_s)
    raise TimeoutError("document did not complete in time")

document = wait_for_completion(document_id)

pdf = requests.get(f"{BASE}/documents/{document_id}/files/signed", headers=HEADERS)
with open(f"{document_id}-signed.pdf", "wb") as f:
    f.write(pdf.content)

Need a hand? Contact us

Document API reference — Help