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
- Sign in and create a key on Dashboard → API keys. Copy it straight away: it is shown once.
- Call the API with the key in the
Authorizationheader.
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" }| Status | Meaning | Example codes |
|---|---|---|
| 400 | The request is incomplete or not valid. | invalid_request (with issues), NO_FILE, BAD_PDF, SIGNER_EMAIL, FIELD_BOUNDS, WRONG_PASSWORD |
| 401 | No key, an unknown key, or a revoked key. | missing_api_key, invalid_api_key, revoked_api_key |
| 403 | Your account may not do this. | EMAIL_NOT_VERIFIED, tool_blocked, account_inactive |
| 404 | Not found, or not yours. | NOT_FOUND, NO_FINAL, not_found |
| 409 | The document's state does not allow it. | CLOSED, SIGNATURE_FIELD_ALREADY_SIGNED |
| 413 | The file is too large. | file_too_large, FILE_TOO_LARGE |
| 422 | The file could not be processed. | invalid_pdf, encrypted_pdf |
| 429 | A limit was reached. Wait for Retry-After seconds. | rate_limited, limit_reached, DAILY_LIMIT |
| 5xx | Our fault. Safe to retry with backoff. | service_unavailable |
Limits
- 60 requests a minute per key. Every response carries
RateLimit-Limit,RateLimit-RemainingandRateLimit-Resetheaders; a 429 carriesRetry-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 field | Type | Notes |
|---|---|---|
title | string | Required, up to 150 characters. The email subject. |
message | string | Optional note in the invitation, up to 2,000 characters. |
sequential | boolean | Required. true: one after another by order. false: everyone at once. |
expiresInDays | integer | Required, 1–60. |
reminderEveryDays | integer | Optional automatic reminders, every 1–14 days. |
signers[] | array | name, 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[] | array | signer (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| Option | Default | Notes |
|---|---|---|
reason, location | — | Shown in the signature and its stamp. |
hashAlgorithm | SHA-256 | or SHA-512. |
visible | true | false for a signature with no stamp on the page. |
placement | bottom-right | bottom-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. |
certify | none | no-changes, form-filling, form-filling-and-comments: a certification signature (first signature only). |
lockDocument | false | Lock the form fields after signing. |
timestamp | true | Add a trusted timestamp. |
ltv, archiveTimestamp | false | Long-term validation data (needs timestamp), and an archive timestamp (needs ltv). |
showName, showDate, showReason, showLocation, showLabels | true | What 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.
| Event | When |
|---|---|
request.sent | A request was created and the first invitations went out. |
signer.viewed | A signer opened the document for the first time. |
signer.signed | A signer (or witness) signed. |
signer.approved | An approver approved. |
signer.declined | A signer declined; the request is closed. |
signer.reassigned | A signer handed their turn to someone else. |
request.completed | Everyone signed; the final document is ready. |
request.expired | The expiry date passed before everyone signed. |
request.cancelled | You cancelled the request. |
ping | You 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
2xxwithin 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 inKovaPDF-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 "", 200Rotating a secret on the dashboard takes effect immediately: deliveries from then on (including retries of older events) are signed with the new secret.