Developer API

Build signatures into your own software

The KovaPDF API gives your server the same three signature tools the website has: send a PDF to other people for signature and follow it with webhooks, sign a PDF with your own certificate, and verify the signatures in any PDF. It is a plain JSON-over-HTTPS REST API. No SDK needed.

Quick start

  1. Sign in and create a key on Dashboard → API keys. Copy it straight away: it is shown once.
  2. Call the API with the key in the Authorization header.
export KOVAPDF_KEY="kova_..."

curl https://api.kovapdf.com/api/v1/me \
  -H "Authorization: Bearer $KOVAPDF_KEY"

All endpoints live under https://api.kovapdf.com/api/v1. Requests with files are multipart/form-data; everything else is JSON. Responses are JSON, except the endpoints that return a PDF.

Authentication

Every request carries Authorization: Bearer <key>. A key acts as the account that made it. Requests you send are yours, signed-in cookies are ignored, and anything you could not do in the browser you cannot do with a key either.

  • Keys start with kova_. We store only a SHA-256 hash of each key, so a lost key cannot be shown again. Revoke it and create a new one.
  • Keep keys on your server. Never put one in a web page, a mobile app or a public repository.
  • Revoking a key takes effect on the next request. You can have up to 10 active keys.

Errors

Every error has a normal HTTP status and a JSON body with a human-readable error and a stable machine-readable code. Switch on code; the wording of error may change.

HTTP/1.1 401 Unauthorized
{ "error": "This API key has been revoked. Create a new one from your dashboard.", "code": "revoked_api_key" }
StatusMeaningExample codes
400The request is incomplete or not valid.invalid_request (with issues), NO_FILE, BAD_PDF, SIGNER_EMAIL, FIELD_BOUNDS, WRONG_PASSWORD
401No key, an unknown key, or a revoked key.missing_api_key, invalid_api_key, revoked_api_key
403Your account may not do this.EMAIL_NOT_VERIFIED, tool_blocked, account_inactive
404Not found, or not yours.NOT_FOUND, NO_FINAL, not_found
409The document's state does not allow it.CLOSED, SIGNATURE_FIELD_ALREADY_SIGNED
413The file is too large.file_too_large, FILE_TOO_LARGE
422The file could not be processed.invalid_pdf, encrypted_pdf
429A limit was reached. Wait for Retry-After seconds.rate_limited, limit_reached, DAILY_LIMIT
5xxOur fault. Safe to retry with backoff.service_unavailable

Limits

  • 60 requests a minute per key. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers; a 429 carries Retry-After.
  • Your account's own limits still apply . They are the same as in the browser: any hourly allowance on your account, 25 signature requests a day, 30 sends and reminders an hour, and any tool an administrator has switched off.
  • Files up to 15 MB for signature requests. For signing and verifying, the same size and page limits as the website.

Request signatures

Send a PDF to one or more people. Each gets an email with a private link, signs in their browser, and everyone receives the finished document, sealed and with a certificate of completion. Your account needs a verified email address. The request goes out in your name.

POST/sign-requests

Multipart form with two parts: file (the PDF) and spec (a JSON string describing the request).

curl https://api.kovapdf.com/api/v1/sign-requests \
  -H "Authorization: Bearer $KOVAPDF_KEY" \
  -F "file=@agreement.pdf" \
  -F 'spec={
    "title": "Services Agreement",
    "message": "Please sign by Friday.",
    "sequential": true,
    "expiresInDays": 14,
    "reminderEveryDays": 3,
    "signers": [
      { "name": "John Smith",   "email": "john@example.com",  "order": 1 },
      { "name": "Emily Carter", "email": "emily@example.com", "order": 2, "accessCode": "NW2026" }
    ],
    "fields": [
      { "signer": 0, "kind": "SIGNATURE", "page": 1, "x": 72,  "y": 90, "width": 180, "height": 48 },
      { "signer": 0, "kind": "DATE",      "page": 1, "x": 72,  "y": 50, "width": 180, "height": 16 },
      { "signer": 1, "kind": "SIGNATURE", "page": 1, "x": 340, "y": 90, "width": 180, "height": 48 }
    ]
  }'
spec fieldTypeNotes
titlestringRequired, up to 150 characters. The email subject.
messagestringOptional note in the invitation, up to 2,000 characters.
sequentialbooleanRequired. true: one after another by order. false: everyone at once.
expiresInDaysintegerRequired, 1–60.
reminderEveryDaysintegerOptional automatic reminders, every 1–14 days.
signers[]arrayname, email, order (from 1), optional accessCode (4–12 letters or digits, sent to them separately by you), optional role: SIGNER (default), APPROVER, WITNESS (with witnessFor: the index of the signer they witness) or CC.
fields[]arraysigner (index into signers), kind (SIGNATURE, INITIALS, NAME, DATE, TEXT, CHECKBOX, DROPDOWN, RADIO, ATTACHMENT, FORMULA), page (from 1), x, y, width, height in PDF points from the page's lower-left corner, optional required and label. Every signer needs at least one SIGNATURE field. Optional options: choices and defaultValue for a dropdown; group and choice for each radio button (two or more per group); validate (number, email, date with dateFormat) and maxLength for text; formula such as [Qty] * [Price] over the same signer's number fields, with decimals; appendToPdf for an attachment; and on any field but a signature showIf: { field, value }, where field is the index of the same signer's checkbox ("true"/"false"), dropdown, radio button or text field.

Returns 201 with the request, the same object as GET /sign-requests/:id. Each signer in it has a link: their private signing link, if you want to show it in your own app as well as by email.

GET/sign-requests

Your requests, newest first (up to 200), with each signer's status.

curl https://api.kovapdf.com/api/v1/sign-requests -H "Authorization: Bearer $KOVAPDF_KEY"

GET/sign-requests/:id

One request: status (IN_PROGRESS, COMPLETED, DECLINED, CANCELLED, EXPIRED), its signers with their status and times, and the full audit trail in events.

{
  "id": "4f0c…",
  "title": "Services Agreement",
  "status": "IN_PROGRESS",
  "sequential": true,
  "expiresAt": "2026-10-06T09:12:00.000Z",
  "hasFinal": false,
  "signers": [
    { "id": "aa57…", "name": "John Smith", "email": "john@example.com", "order": 1,
      "status": "SIGNED", "signedAt": "2026-09-22T09:30:11.000Z", "link": "https://…/sign/…" },
    { "id": "c1d2…", "name": "Emily Carter", "email": "emily@example.com", "order": 2,
      "status": "SENT", "isTurn": true, "link": "https://…/sign/…" }
  ],
  "events": [ { "at": "…", "type": "CREATED", "text": "Created and sent by the sender (2 signer(s), in order)" } ]
}

POST/sign-requests/:id/remind

Emails the people whose turn it is again. Optional JSON body { "signerId": "…" } to remind one person. Each person can be reminded once every 12 hours; anyone reminded more recently is listed in skipped.

curl -X POST https://api.kovapdf.com/api/v1/sign-requests/$ID/remind \
  -H "Authorization: Bearer $KOVAPDF_KEY" -H "Content-Type: application/json" -d '{}'
# { "sent": 1, "skipped": [] }

POST/sign-requests/:id/cancel

Closes a request that is still waiting for signatures. The signing links stop working.

curl -X POST https://api.kovapdf.com/api/v1/sign-requests/$ID/cancel -H "Authorization: Bearer $KOVAPDF_KEY"

GET/sign-requests/:id/document?version=original|current|final

Downloads a PDF. original (default) is the file you uploaded; current shows the signatures collected so far; final is the sealed, signed document with its certificate of completion, and exists once everyone has signed (404 NO_FINAL before that).

curl -o signed.pdf "https://api.kovapdf.com/api/v1/sign-requests/$ID/document?version=final" \
  -H "Authorization: Bearer $KOVAPDF_KEY"

Digital signature

POST/digital-sign

Signs a PDF with your own certificate (PAdES) and returns the signed PDF. Multipart fields: file (the PDF), certificate (a .pfx/.p12), password, and any options below. The certificate and password are used once, in memory: they are never stored and never logged.

curl https://api.kovapdf.com/api/v1/digital-sign \
  -H "Authorization: Bearer $KOVAPDF_KEY" \
  -F "file=@contract.pdf" \
  -F "certificate=@me.pfx" \
  -F "password=$PFX_PASSWORD" \
  -F "reason=Approved" -F "location=London" \
  -F "placement=bottom-right" \
  -o contract-signed.pdf
OptionDefaultNotes
reason, location—Shown in the signature and its stamp.
hashAlgorithmSHA-256or SHA-512.
visibletruefalse for a signature with no stamp on the page.
placementbottom-rightbottom-left, top-right, top-left; on the last page unless page is set.
page, rect—Exact position: rect is a JSON array [x1,y1,x2,y2] in points from the lower-left of the page as displayed. Overrides placement.
fieldName—Sign into an existing empty signature field.
signatureImage—A PNG, JPEG or WebP file part to draw in the stamp.
certifynoneno-changes, form-filling, form-filling-and-comments: a certification signature (first signature only).
lockDocumentfalseLock the form fields after signing.
timestamptrueAdd a trusted timestamp.
ltv, archiveTimestampfalseLong-term validation data (needs timestamp), and an archive timestamp (needs ltv).
showName, showDate, showReason, showLocation, showLabelstrueWhat the visible stamp shows.

The response is the PDF. Headers describe what it verifiably has: X-Signature-Profile (e.g. PAdES-B-T), X-Signature-Timestamp, X-Signature-LTV, X-Signature-Hash, X-Signature-Certify, X-Signature-Signer (URL-encoded) and X-Signature-Status (base64 JSON). A wrong password is 400 WRONG_PASSWORD; an expired certificate is 400 CERTIFICATE_EXPIRED.

Verify signatures

POST/verify

Checks every signature in a PDF (integrity, certificate, chain, revocation, timestamp and whether the document changed after signing) and returns the same report the Verify tool shows.

curl https://api.kovapdf.com/api/v1/verify \
  -H "Authorization: Bearer $KOVAPDF_KEY" \
  -F "file=@signed.pdf"
{
  "fileSha256": "9c1e…",
  "pageCount": 3,
  "signatures": [
    {
      "verdict": "UNCHANGED",
      "status": { "signature": "VALID", "certificate": "VALID", "certificateChain": "VALID",
                  "revocation": "NOT_REVOKED", "trust": "TRUSTED", "timestamp": "VALID",
                  "ltv": "PRESENT_NOT_VERIFIED", "pades": "B-LT_STRUCTURALLY_CONFORMANT" },
      …
    }
  ],
  …
}

verdict per signature is one of UNCHANGED, ALLOWED_CHANGES, CHANGED, REVOKED or BROKEN. A PDF with no signatures returns an empty signatures array.

Webhooks

Add an HTTPS endpoint on Dashboard → API keys and we will POST a JSON event to it whenever one of your signature requests changes, including requests sent from the website. Choose which events to receive, or leave them all on.

EventWhen
request.sentA request was created and the first invitations went out.
signer.viewedA signer opened the document for the first time.
signer.signedA signer (or witness) signed.
signer.approvedAn approver approved.
signer.declinedA signer declined; the request is closed.
signer.reassignedA signer handed their turn to someone else.
request.completedEveryone signed; the final document is ready.
request.expiredThe expiry date passed before everyone signed.
request.cancelledYou cancelled the request.
pingYou pressed “Send test” on the dashboard.
POST /your/webhook HTTP/1.1
Content-Type: application/json
KovaPDF-Event: signer.signed
KovaPDF-Delivery: 7b9eebb3-91bb-4feb-991a-63685a471937
KovaPDF-Signature: t=1790000000,v1=5f2c…

{
  "id": "7b9eebb3-91bb-4feb-991a-63685a471937",
  "type": "signer.signed",
  "createdAt": "2026-09-22T09:30:11.482Z",
  "data": {
    "request": { "id": "4f0c…", "title": "Services Agreement", "status": "IN_PROGRESS",
                 "createdAt": "…", "completedAt": null, "expiresAt": "…" },
    "signer":  { "id": "aa57…", "name": "John Smith", "email": "john@example.com", "role": "SIGNER", "status": "SIGNED" }
  }
}

Delivery and retries

  • Answer with any 2xx within 10 seconds. Anything else (an error status, a redirect, a timeout) counts as a failure.
  • Failures are retried after 10 s, 1 min, 5 min, 30 min, 2 h and 6 h (7 attempts in all). The last 20 deliveries per endpoint, with their status, are on the dashboard, where you can also resend one.
  • Delivery is at-least-once and not strictly ordered. Use the id (also in KovaPDF-Delivery) to ignore duplicates, and fetch the request when you need its latest state.

Verifying the signature

Every delivery is signed with your endpoint's signing secret (whsec_…, shown on the dashboard).KovaPDF-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw request body>. Compute it over the raw body bytes (before any JSON parsing) compare in constant time, and reject timestamps more than five minutes old.

Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.KOVAPDF_WEBHOOK_SECRET; // whsec_...

function verifyKovaSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !parts.v1) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

const app = express();
app.post("/kovapdf/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyKovaSignature(raw, req.get("KovaPDF-Signature") ?? "", SECRET)) return res.sendStatus(400);
  const event = JSON.parse(raw);
  // ...handle event.type, deduplicate on event.id...
  res.sendStatus(200);
});
app.listen(3000);

Python (Flask)

import hashlib, hmac, os, time
from flask import Flask, request, abort

SECRET = os.environ["KOVAPDF_WEBHOOK_SECRET"]  # whsec_...

def verify_kova_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

app = Flask(__name__)

@app.post("/kovapdf/webhook")
def kovapdf_webhook():
    raw = request.get_data()  # raw bytes, before parsing
    if not verify_kova_signature(raw, request.headers.get("KovaPDF-Signature", ""), SECRET):
        abort(400)
    event = request.get_json()
    # ...handle event["type"], deduplicate on event["id"]...
    return "", 200

Rotating a secret on the dashboard takes effect immediately: deliveries from then on (including retries of older events) are signed with the new secret.