Signatures from your own software
One request sends a PDF for signature. Webhooks tell you when it is opened, signed and sealed. Every sealed file can be verified by anyone, with or without a key.
Quickstart
- Create an API key under Integrations. Start with a test key: it behaves exactly like a live one but sends no e-mail.
- POST a PDF and the signers. The response carries one secure signing link per signer.
- Register a webhook (or poll the status endpoint) and download the sealed PDF and its certificate when the document is finalized.
curl -X POST https://sealique.com/api/v1/documents \
-H "Authorization: Bearer af_test_…" \
-H "Idempotency-Key: order-1042" \
-F "file=@contract.pdf" \
-F "title=Service agreement" \
-F 'signers=[{"name":"Aline Uwase","email":"aline@example.com","title":"CEO"},
{"name":"Eric Mugisha","email":"eric@example.com"}]' \
-F "signing_order=sequential" \
-F "return_url=https://app.example.com/signed"
import requests
r = requests.post(
"https://sealique.com/api/v1/documents",
headers={"Authorization": "Bearer af_test_…", "Idempotency-Key": "order-1042"},
files={"file": open("contract.pdf", "rb")},
data={
"title": "Service agreement",
"signers": '[{"name": "Aline Uwase", "email": "aline@example.com", "title": "CEO"}]',
"signing_order": "sequential",
"return_url": "https://app.example.com/signed",
},
timeout=30,
)
doc = r.json()
print(doc["status"], doc["signing_links"][0]["signing_url"])
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("contract.pdf")], { type: "application/pdf" }), "contract.pdf");
form.append("title", "Service agreement");
form.append("signers", JSON.stringify([{ name: "Aline Uwase", email: "aline@example.com", title: "CEO" }]));
form.append("signing_order", "sequential");
const res = await fetch("https://sealique.com/api/v1/documents", {
method: "POST",
headers: { Authorization: "Bearer af_test_…", "Idempotency-Key": "order-1042" },
body: form,
});
const doc = await res.json();
console.log(doc.status, doc.signing_links[0].signing_url);
Prefer JSON? Send Content-Type: application/json with file_base64 instead of the multipart file; every other option is the same.
{
"id": "6f1c…", "status": "sent", "mode": "test", "title": "Service agreement",
"signing_order": "sequential", "auto_finalize": true,
"signers": [{"id": "…", "name": "Aline Uwase", "email": "aline@example.com", "status": "invited",
"signing_url": "https://sealique.com/sign/…", "link_expires_at": "2026-11-08T10:00:00+00:00"}],
"signing_links": [{"id": "…", "name": "Aline Uwase", "email": "aline@example.com",
"signing_url": "https://sealique.com/sign/…", "expires_at": "…"}],
"verification_id": null, "verify_url": null, "document_hash": null, "finalized_at": null,
"certificate_url": null, "file_url": null, "metadata": {}, "events": [ … ]
}
Authentication, modes and scopes
Every key-authenticated request carries Authorization: Bearer <key>. Keys are shown once, at creation, and can be revoked at any time.
Modes
af_live_…— real documents, invitations are e-mailed, webhooks go to live endpoints.af_test_…— sandbox: documents are flagged, nobody is e-mailed (you receive the links in the response), webhooks go to test endpoints only. The signing pages and sealing work exactly as in live mode.
Every response echoes "mode": "live" | "test". A key only sees the documents created in its own mode.
Scopes
documents:readdocuments:writeverifywebhooks
A key without explicit scopes has all of them. A call outside the key's scopes gets 403 insufficient_scope. Keys may carry an expiry date; expired keys get 401 unauthorized.
Document lifecycle
Side exits: declined when a signer refuses, canceled when you call cancel. With auto_finalize (the default) the step from completed to finalized happens by itself; otherwise call POST …/finalize.
Sealing stamps every signature into the PDF, applies the platform certification signature and one PAdES signature per signer, then hashes the file (SHA-256) and issues a verification ID printed in every signature block. The certificate of completion is a separate PDF.
Endpoints
Full reference with schemas| Method | Path | Purpose | Scope |
|---|---|---|---|
POST | /api/v1/documents | Create (multipart or JSON file_base64). Options: title, signers[{name,email,title,order}], signing_order, auto_finalize (default true), message, send (default true), return_url, require_otp, metadata, suppress_email. | documents:write |
GET | /api/v1/documents?status=&cursor=&limit= | List your documents, newest first, cursor-paginated. | documents:read |
GET | /api/v1/documents/{id} | Status, signers, links, verification_id, verify_url, document_hash, finalized_at, certificate_url, file_url, events, metadata. | documents:read |
POST | /api/v1/documents/{id}/signers | Add a signer (draft or sent). | documents:write |
DELETE | /api/v1/documents/{id}/signers/{sid} | Remove a signer who has not signed. | documents:write |
POST | /api/v1/documents/{id}/send | Send a draft for signature (message, signing_order, auto_finalize). | documents:write |
POST | /api/v1/documents/{id}/signers/{sid}/remind | Remind a signer; new_link=true issues a fresh link. | documents:write |
GET | /api/v1/documents/{id}/signers/{sid}/link | Current signing link and expires_at; ?regenerate=true revokes it and returns a new one without e-mailing. | documents:read |
POST | /api/v1/documents/{id}/finalize | Seal (409 unless completed). | documents:write |
POST | /api/v1/documents/{id}/cancel | Cancel; every link stops working. | documents:write |
GET | /api/v1/documents/{id}/file | The sealed PDF. | documents:read |
GET | /api/v1/documents/{id}/certificate | The certificate of completion (PDF). | documents:read |
GET | /api/v1/verify/{vid} | Public verification record for a verification ID. | none |
POST | /api/v1/verify | Upload any PDF: authentic, modified after sealing, tampered, flattened copy, unsigned… | none |
GET | /api/v1/verify/{vid}/page/{n}/{digest} | Does this page fingerprint belong to the sealed document? | none |
GET POST | /api/v1/webhooks | List or add endpoints (the secret is returned once). | webhooks |
GET DELETE | /api/v1/webhooks/{id} | Endpoint with its recent deliveries; remove it. | webhooks |
POST | /api/v1/webhooks/{id}/test | Send a ping event. | webhooks |
GET | /api/v1/openapi.json | OpenAPI 3.1 description. | none |
Follow a document
# status
curl https://sealique.com/api/v1/documents/$ID -H "Authorization: Bearer af_live_…"
# seal once everyone signed (not needed with auto_finalize)
curl -X POST https://sealique.com/api/v1/documents/$ID/finalize -H "Authorization: Bearer af_live_…"
# sealed PDF and certificate
curl -o signed.pdf https://sealique.com/api/v1/documents/$ID/file -H "Authorization: Bearer af_live_…"
curl -o certificate.pdf https://sealique.com/api/v1/documents/$ID/certificate -H "Authorization: Bearer af_live_…"
H = {"Authorization": "Bearer af_live_…"}
doc = requests.get(f"https://sealique.com/api/v1/documents/{doc_id}", headers=H, timeout=30).json()
if doc["status"] == "completed":
doc = requests.post(f"https://sealique.com/api/v1/documents/{doc_id}/finalize", headers=H, timeout=120).json()
if doc["status"] == "finalized":
pdf = requests.get(doc["file_url"], headers=H, timeout=60).content
cert = requests.get(doc["certificate_url"], headers=H, timeout=60).content
const H = { Authorization: "Bearer af_live_…" };
let doc = await (await fetch(`https://sealique.com/api/v1/documents/${id}`, { headers: H })).json();
if (doc.status === "completed") {
doc = await (await fetch(`https://sealique.com/api/v1/documents/${id}/finalize`, { method: "POST", headers: H })).json();
}
if (doc.status === "finalized") {
const pdf = Buffer.from(await (await fetch(doc.file_url, { headers: H })).arrayBuffer());
}
Errors
Every error has the same shape and an HTTP status that matches:
{"error": {"code": "invalid_state", "message": "The document can be finalized once every signer has signed.", "status": 409}, "mode": "live"}
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | Missing, revoked or expired key. |
insufficient_scope | 403 | The key lacks the scope the endpoint needs. |
not_found | 404 | No such document, signer, endpoint or verification ID for this key. |
invalid_request · invalid_json · invalid_file · invalid_cursor | 400 | A parameter, body, file or cursor is malformed. |
invalid_state | 409 | The action does not fit the document's status. |
payload_too_large | 413 | PDF over 30 MB or JSON body over 40 MB. |
rate_limited | 429 | Retry after Retry-After seconds. |
verification_unavailable | 503 | Verification by upload is not available on this server. |
Webhooks
Register an https URL and we POST a JSON event whenever one of your documents changes. Live documents reach live endpoints, sandbox documents reach test endpoints. An endpoint takes the mode of the key that registers it, so a test key only manages test endpoints. The host must be public: URLs pointing at private, loopback or link-local addresses are rejected.
Events
document.sentsigner.openedsigner.signedsigner.declineddocument.completeddocument.finalizeddocument.canceledping— from the test button
Headers
X-Sealique-Signature: t=<unix>,v1=<hex>— HMAC-SHA256 of"{t}.{body}"with your endpoint secretX-Sealique-Event— the event nameX-Sealique-Delivery— unique delivery ID (deduplicate on it)
Answer with any 2xx within a few seconds. Anything else is retried with back-off (1 min, 5 min, 30 min, 2 h, 12 h, 1 day) up to 6 attempts.
{
"id": "delivery-uuid", "event": "signer.signed", "created_at": "2026-10-09T10:12:00+00:00", "mode": "live",
"data": {
"document": { "id": "…", "status": "sent", "signers": [ … ], "metadata": {"order": 1042}, … },
"signer": { "id": "…", "name": "Aline Uwase", "email": "aline@example.com", "status": "signed", "signed_at": "…" }
}
}
Verify the signature
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask / FastAPI / Django: pass the raw request body, not a re-serialised dict
ok = verify(WEBHOOK_SECRET, request.headers["X-Sealique-Signature"], request.get_data())
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// express: app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => { verify(SECRET, req.get("X-Sealique-Signature"), req.body) … })
Embedding the signing experience
Hosted link
The simplest: send or show the signing_url. It works on any device, needs no account and expires with the document (30 days by default; regenerate it through the API).
return_url
Pass return_url when creating the document: after signing (or declining) the signer is sent there with ?event=signed|declined&document=<id>&signer=<id>. Confirm the outcome server-side through the status endpoint or a webhook, never from the query string alone.
iframe
Add your origins to the key's allowed_origins (https only). Signing pages of your documents then answer with Content-Security-Policy: frame-ancestors for those origins and may be framed. When the signer finishes, the page posts a message to the parent window:
window.addEventListener("message", (e) => {
if (e.origin !== "https://sealique.com") return;
if (e.data?.type === "sealique" && e.data.event === "signed") {
// e.data.document, e.data.signer
}
});
Identity confirmation: set require_otp and each signer must enter a one-time code e-mailed to them before the document opens. Every signer also accepts explicit consent statements; both are recorded in the audit trail and the certificate.
Verification
Three public, key-less ways to check a document. All are rate limited per IP (20 per minute).
# by file: authentic | modified_after_sealing | tampered | flattened_copy | unsigned_unknown | …
curl -F "file=@signed.pdf" https://sealique.com/api/v1/verify
# by verification ID (printed on every page)
curl https://sealique.com/api/v1/verify/VF-3F9A1C-7B2D0E
# one page's fingerprint (the QR in a page mark)
curl https://sealique.com/api/v1/verify/VF-3F9A1C-7B2D0E/page/3/1a2b3c4d
result = requests.post("https://sealique.com/api/v1/verify", files={"file": open("signed.pdf", "rb")}, timeout=60).json()
print(result["code"], result.get("verification_id"), result["match"])
const form = new FormData();
form.append("file", new Blob([await readFile("signed.pdf")]), "signed.pdf");
const result = await (await fetch("https://sealique.com/api/v1/verify", { method: "POST", body: form })).json();
console.log(result.code, result.verification_id, result.match);
Outcome codes: authentic (byte-identical to the sealed file), modified_after_sealing (signatures intact but the file changed, e.g. annotations), tampered (a signature no longer validates), flattened_copy (a print or scan of a sealed document; text compared page by page), unsigned_unknown, unparseable, foreign_root, not_found, too_large.
Rate limits and idempotency
- Per key: 120 requests per minute. Over it you get
429 rate_limitedwith aRetry-Afterheader (seconds). - Verification endpoints: 20 per minute per IP.
- Send an
Idempotency-Keyheader (up to 128 characters, unique per key) withPOST /api/v1/documents. A retry with the same key replays the first response (Idempotent-Replayed: true) instead of creating a second document. - Lists are cursor-paginated:
limit(1–100, default 25) andnext_cursorfrom the previous page. - PDFs up to 30 MB; metadata up to 8 KB; signing links valid 30 days.