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
Test environment (UAT)
https://app.kovasignbeta.work/api/v1
This is the test environment. Production is a separate host (https://app.kovasign.com/api/v1) and needs its own token from your production workspace.
- 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.envblock:KOVASIGN_API_URL,KOVASIGN_API_TOKEN,KOVASIGN_ON_BEHALF_OFandKOVASIGN_WEBHOOK_SECRET. - Pick the member to act as. Every
/documentscall names a workspace member inX-On-Behalf-Of(see Authentication). If your company signs too, that member needs a saved signature in Kovasign. - Put tags in your PDF. Type
{{sig:signer1}},{{date:signer1}}… where fields go, in white text (see Placement contract). - Create, sign, send.
POST /documents(multipart:file+metawithroles), thenPOST /documents/{id}/signfor your own slot, thenPOST /documents/{id}/send. Sign-then-send needs your role to have the lowestsigning_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. - 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 callPOST /webhooks, never both. VerifyKovasign-Signature, dedupe onKovasign-Event-Idand wait fordocument.completed(see Webhooks). - Download.
GET /documents/{id}/files/signedfor the sealed PDF and/files/certificatefor 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.
# 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 (UAT/test): https://app.kovasignbeta.work/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; production 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.kovasignbeta.work/help/developers/document-api
- OpenAPI 3.1 spec: https://app.kovasignbeta.work/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.kovasignbeta.work/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 (Test environment (UAT)):
https://app.kovasignbeta.work/api/v1The 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:
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:
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:
| Condition | Status | code |
|---|---|---|
| Header missing | 400 | actor_required |
| No member matches the email/UUID | 404 | actor_not_found |
Member is not active — see reason below | 403 | actor_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 /documentsPOST /documents/{id}/sendPOST /webhooks/{id}/test
It is optional everywhere else. Semantics:
| Situation | Result |
|---|---|
| Same key, same request body, called again | The original response is replayed with header Idempotent-Replayed: true |
| Same key, a different request body | 422 idempotency_key_reused |
| Same key, a request is still in flight | 409 idempotency_in_progress |
| Required but missing | 400 idempotency_key_required |
| Over 128 characters | 400 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/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).
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.
| Type | Kovasign field | Default box (% of page W × H) |
|---|---|---|
sig | signature | 15 × 5 |
initials | initials | 8 × 4 |
date | date (auto-filled at signing) | 20 × 3 |
text | text | 20 × 3 |
checkbox | checkbox | 4 × 4 |
stamp | company stamp | 10 × 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:
{ "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:
{ "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
reasonenum), 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 + path | Purpose | Idempotency-Key | Notable errors |
|---|---|---|---|
POST /documents | Create a document — from an uploaded file, a URL, or a template — optionally mapping roles and placing fields in the same call | Required | validation_error, placement_unresolved (no document is created), payload_too_large, source_fetch_failed |
GET /documents | List documents (cursor-paginated) | — | invalid_cursor |
GET /documents/{id} | Get one document, its recipients and its field geometry | — | not_found |
GET /documents/{id}/text | Read the text layer, for anchor discovery | — | not_found |
POST /documents/{id}/recipients | Add a recipient to a draft | Optional | validation_error, not_found |
POST /documents/{id}/fields | Place fields on a draft, via tags, anchors or coordinates | Optional | placement_unresolved |
POST /documents/{id}/send | Send the document for signing | Required | attestation_required, already_sent, billing_past_due, seat_suspended |
POST /documents/{id}/sign | Sign the acting member's own slot | Optional | intent_required, not_your_slot, singpass_required, no_saved_signature, already_signed |
POST /documents/{id}/remind | Nudge one recipient | Optional | rate_limited (3 per recipient per 24h) |
POST /documents/{id}/recall | Pull a sent document back to draft | Optional | not_recallable |
POST /documents/{id}/void | Recall (if needed) and archive — see Void | Optional | not_found, conflict |
POST /documents/{id}/save-as-template | Save as a reusable template | Optional | forbidden (plan cap), conflict (no PDF to copy) |
GET /documents/{id}/files/signed | Download the signed PDF | — | not_completed |
GET /documents/{id}/files/certificate | Download the Signing Log Certificate | — | not_completed |
GET /documents/{id}/audit | Read 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 + path | Purpose | Idempotency-Key | Notable errors |
|---|---|---|---|
GET /templates | List 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 + path | Purpose | Idempotency-Key | Notable errors |
|---|---|---|---|
GET /webhooks | List this tenant's endpoints | — | — |
POST /webhooks | Register an endpoint — mints and returns the secret once | Optional | webhook_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 state | Optional | secret_required |
DELETE /webhooks/{id} | Remove an endpoint | Optional | not_found |
POST /webhooks/{id}/secret | Rotate the signing secret | Optional | not_found |
POST /webhooks/{id}/test | Send a synthetic document.test ping | Required | not_found |
GET /webhooks/{id}/deliveries | List 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 + path | Purpose | Idempotency-Key | Notable errors |
|---|---|---|---|
GET /signatory | The 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-readiness | Can 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
type | When | recipient_id | Extra data keys |
|---|---|---|---|
document.sent | The document left draft for pending | — | — |
recipient.sent | A signer's link was issued — every step-1 signer at send, then each signer as a sequential cascade reaches them | ✓ | — |
recipient.viewed | A signer opened their link for the first time | ✓ | — |
recipient.signed | One signer completed their signature | ✓ | — |
recipient.declined | A signer refused | ✓ | reason |
document.completed | The last signer landed; emitted once per document | — | — |
document.declined | The document stopped because a signer declined | ✓ (the decliner) | reason |
document.expired | Expiry passed with signers still outstanding | — | — |
document.recalled | The sender pulled it back to draft; links died | — | — |
document.voided | The document was voided — see Void | — | previous_status, reason |
document.test | Synthetic — 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:
{"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
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 deliveryVerifying a delivery
- Build
signed = `${t}.${rawBody}`from the timestamp in the header and the raw request body — never re-serialise the parsed JSON. - Compute
v1 = hex(HMAC-SHA256(secret, signed)). - Reject the delivery if
|now − t| > 300s. - Accept if any
v1term in the header matches any secret you currently hold (both the current and, during a rotation, the previous secret). - Compare in constant time.
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:
| Attempt | Delay after previous | Elapsed |
|---|---|---|
| 1 | — (delivered within ~1–2s; 60s backstop) | ~0 |
| 2 | 1 min | ~1 min |
| 3 | 5 min | ~6 min |
| 4 | 30 min | ~36 min |
| 5 | 2 h | ~2.6 h |
| 6 | 6 h | ~8.6 h |
| 7 | 24 h | ~33 h |
Response handling
| Your response | Meaning | Action |
|---|---|---|
2xx | Delivered | Done. |
410 Gone | Endpoint retired | Stops retrying this event and disables that endpoint (disabled_reason: "endpoint_gone"); other endpoints are untouched. |
Other 4xx | Your handler rejected it | Permanent failure for this event — no retry. |
408, 429 | Transient | Retry, honouring Retry-After when present (clamped to ≤24h). |
5xx | Your side broke | Retry. |
| Timeout (10s) / DNS / TLS / connection refused | Transient | Retry. |
3xx | Redirect | Treated 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 refused | Status | code |
|---|---|---|
| The delivery is already queued | 409 | already_pending |
| The endpoint is switched off | 409 | endpoint_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
intentobject is required —{user_confirmed: true, confirmed_at, surface, actor_ref?}— recorded as{via: "api", api_token_id, intent_confirmed_at, attestation}. ip_addresson this signature is written asNULL— 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).
{
"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 withsignatory.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.
// 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.voidedevent withdata.previous_statusset topending; no separatedocument.recalledis 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.
| code | Status | Meaning |
|---|---|---|
unauthorized | 401 | Missing, malformed or unrecognised bearer token |
token_expired | 401 | Token past its rotation grace window |
rate_limited | 429 | Tenant or token rate limit exceeded — see Retry-After |
enterprise_required | 403 | Tenant is not on the Enterprise plan |
actor_required | 400 | X-On-Behalf-Of missing |
actor_not_found | 404 | No member matches the email/UUID given |
actor_not_active | 403 | Member exists but can't act — see reason |
idempotency_key_required | 400 | Idempotency-Key required but missing |
idempotency_key_invalid | 400 | Key over 128 characters |
idempotency_key_reused | 422 | Same key, different request body |
idempotency_in_progress | 409 | Same key, an earlier call is still in flight |
validation_error | 400 | Request body failed schema validation — see issues[] |
invalid_cursor | 400 | Pagination cursor is malformed or stale |
payload_too_large | 413 | Uploaded file over 25MB |
source_fetch_failed | 400 | Fetching a source.url document failed — see its reason |
webhook_cap_reached | 400 | Tenant already has 3 webhook endpoints |
secret_required | 400 | Enabling an endpoint with no secret set |
invalid_url | 400 | Webhook URL isn't a well-formed HTTPS URL |
insecure_scheme | 400 | Webhook or source URL isn't https:// |
ssrf_blocked | 400 | URL resolves to a private/internal address |
dns_error | 400 | URL's host doesn't resolve |
company_required | 409 | GET /signatory: several legal entities — pass ?company=; see companies[] |
company_not_found | 404 | GET /signatory: ?company= matches none of the workspace's legal entities |
signatory_not_set | 404 | GET /signatory: no legal entity, or no signatory email on it — set it under Workspace → Company |
placement_unresolved | 422 | One or more fields didn't resolve — see errors[] and Placement contract |
seat_suspended | 403 | The acting member's seat is suspended |
billing_past_due | 402 | Tenant's billing is past due — sending is blocked |
already_sent | 409 | Document is not a draft |
not_recallable | 409 | Document already completed or declined |
not_found | 404 | Resource doesn't exist or isn't visible to the actor |
forbidden | 403 | Action not permitted (e.g. plan cap) |
conflict | 409 | Action doesn't apply to the resource's current state |
internal_error | 500 | Unexpected server error — safe to retry |
attestation_required | 400 | Send called without an attestation object |
intent_required | 400 | Self-sign called without an intent object |
not_your_slot | 403 | Actor tried to sign a recipient slot that isn't theirs |
singpass_required | 409 | That slot requires Singpass — not headless-signable |
no_saved_signature | 409 | Actor 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_signed | 409 | That slot is already signed |
not_your_turn | 409 | Sequential signing order hasn't reached this slot yet |
document_voided | 409 | The document was voided: it can't be signed or sent again. Create a new document (new Idempotency-Key) |
not_completed | 409 | Requested a signed file before every signer finished |
file_not_ready | 409 | Sealed PDF still being produced — retry shortly |
invalid_created_after | 400 | Malformed 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
curl -X POST https://app.kovasignbeta.work/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
// 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.kovasignbeta.work/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
import os
import time
import requests
BASE = os.environ.get("KOVASIGN_API_URL", "https://app.kovasignbeta.work/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