Overview
A CalmSign document is an ordered list of blocks — headings, paragraphs, inputs, and signature fields. You create one from your own blocks or from a saved template, send it to one or more signers, and CalmSign handles the signing link, the reminders, and the copies. When the last signer is done the document is sealed with a SHA-256 hash over an immutable snapshot, and the full audit trail is available for download.
Signers never need an account. They open a link, fill the inputs, sign, and download their copy.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /v1/documents | Create a document from blocks or a template |
| GET | /v1/documents | List documents |
| GET | /v1/documents/{id} | Retrieve one document and its status |
| POST | /v1/documents/{id}/send | Send a document for signature |
| GET | /v1/documents/{id}/pdf | Download the signed PDF |
| GET | /v1/documents/{id}/audit-trail | Download the audit trail |
| GET | /v1/documents/{id}/seal | Read and check the SHA-256 seal |
Authentication
Every request carries an API key in an Authorization header. Create keys in the app under Settings → API keys; the secret is shown once, at creation. Keys are scoped to one workspace and can be revoked at any time without touching the documents they created.
curl https://api.usecalmsign.com/v1/documents \
-H "Authorization: Bearer cs_live_9c2f4a7b1d0e8f36"Requests over plain HTTP are refused, not redirected. Keep the key server-side — it can read and send every document in the workspace, so it does not belong in browser or mobile code.
Base URL and versioning
All endpoints live under a single versioned base URL:
https://api.usecalmsign.com/v1Requests and responses are JSON, UTF-8, with timestamps as ISO 8601 in UTC. New fields can appear inside existing objects at any time, so parse leniently and ignore what you do not know. Anything that would break a working integration — a removed field, a changed type — ships under a new version prefix instead, and the old prefix keeps working.
Documents
Create a document from blocks
/v1/documentsPost an ordered blocks array. The order you send is the order the signer reads, and the document is created as a draft — nothing is emailed until you call send.
| Field | Type | Required on | Notes |
|---|---|---|---|
type | string | Yes | heading, text, input, or signature. |
content | string | heading / text | The rendered copy of a heading or text block. |
label | string | input / signature | What the signer sees above the field, e.g. "Company Name". |
input_type | string | input | text or date. Dates are captured in the signer’s locale and stored as ISO 8601. |
required | boolean | No | Blocks the signature until the field is filled. Defaults to false. |
curl -X POST https://api.usecalmsign.com/v1/documents \
-H "Authorization: Bearer cs_live_9c2f4a7b1d0e8f36" \
-H "Content-Type: application/json" \
-d '{
"name": "Mutual Non-Disclosure Agreement",
"blocks": [
{ "type": "heading", "content": "Mutual Non-Disclosure Agreement" },
{ "type": "text", "content": "This agreement governs the exchange of confidential information between the parties." },
{ "type": "input", "label": "Company Name", "input_type": "text", "required": true },
{ "type": "input", "label": "Effective Date", "input_type": "date", "required": true },
{ "type": "signature", "label": "Authorized Signature", "required": true }
]
}'{
"id": "doc_7Kq2mR4xa9",
"name": "Mutual Non-Disclosure Agreement",
"status": "draft",
"template": null,
"signers": [],
"created_at": "2026-08-18T10:12:44Z",
"updated_at": "2026-08-18T10:12:44Z"
}Responses omit the blocks array unless you ask for it with?include=blocks. Documents stay editable while they are drafts.
Create a document from a template
/v1/documentsPass a template key instead of blocksand CalmSign copies that template's blocks into a new draft. Every workspace is seeded with the starter templates below; your own saved templates work the same way, using the key shown on the template in the app.
curl -X POST https://api.usecalmsign.com/v1/documents \
-H "Authorization: Bearer cs_live_9c2f4a7b1d0e8f36" \
-H "Content-Type: application/json" \
-d '{
"template": "nda",
"name": "NDA — Northwind Studio"
}'| Key | Template |
|---|---|
nda | Mutual Non-Disclosure Agreement |
msa | Master Services Agreement |
consulting-agreement | Consulting Services Agreement |
employment-offer | Employment Offer Letter |
sales-order | Sales Order |
statement-of-work | Statement of Work |
contractor-agreement | Independent Contractor Agreement |
Browse what each one contains in the template gallery.
Retrieve a document
/v1/documents/{id}Returns the document, each signer's progress, the seal once one exists, and the paths to the signed PDF and the audit trail.
{
"id": "doc_7Kq2mR4xa9",
"name": "Mutual Non-Disclosure Agreement",
"status": "signed",
"template": "nda",
"created_at": "2026-08-18T10:12:44Z",
"sent_at": "2026-08-18T10:15:02Z",
"completed_at": "2026-08-18T14:32:07Z",
"signers": [
{
"id": "sgn_3Vb8tL",
"name": "Jordan Mitchell",
"email": "jordan@example.com",
"order": 1,
"status": "signed",
"signed_at": "2026-08-18T14:32:07Z"
}
],
"seal": {
"algorithm": "sha256",
"value": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"status": "intact"
},
"files": {
"pdf": "/v1/documents/doc_7Kq2mR4xa9/pdf",
"audit_trail": "/v1/documents/doc_7Kq2mR4xa9/audit-trail"
}
}Document statuses
A document moves through five states. Only signed anddeclined are terminal.
| Status | Meaning |
|---|---|
draft | Created but not sent. Blocks can still be edited. |
sent | A signing link has been issued to the current signer. |
viewed | The signer opened the link. Logged with timestamp, IP, and device. |
signed | Every signer has signed. The seal is applied and both sides get a copy. |
declined | A signer declined. The document is closed and cannot be sent again. |
List documents
/v1/documentsNewest first. Filter with status, page withlimit (1–100, default 25) and thenext_cursor returned alongside the results.
curl "https://api.usecalmsign.com/v1/documents?status=sent&limit=25" \
-H "Authorization: Bearer cs_live_9c2f4a7b1d0e8f36"Sending for signature
Send a document
/v1/documents/{id}/sendMoves a draft to sent and emails a private signing link. Give each signer an order to control the sequence: signer 2 is only invited once signer 1 has finished. Omit order and everyone is invited at once.
curl -X POST https://api.usecalmsign.com/v1/documents/doc_7Kq2mR4xa9/send \
-H "Authorization: Bearer cs_live_9c2f4a7b1d0e8f36" \
-H "Content-Type: application/json" \
-d '{
"signers": [
{ "name": "Jordan Mitchell", "email": "jordan@example.com", "order": 1 },
{ "name": "Alex Rivera", "email": "alex@northwind.example", "order": 2 }
],
"message": "Here is the NDA we discussed — it takes about a minute to sign."
}'{
"id": "doc_7Kq2mR4xa9",
"status": "sent",
"sent_at": "2026-08-18T10:15:02Z",
"signers": [
{
"id": "sgn_3Vb8tL",
"name": "Jordan Mitchell",
"email": "jordan@example.com",
"order": 1,
"status": "sent",
"signing_url": "https://app.usecalmsign.com/s/a7x9k2m"
},
{
"id": "sgn_9Wd1pQ",
"name": "Alex Rivera",
"email": "alex@northwind.example",
"order": 2,
"status": "pending",
"signing_url": null
}
]
}What the signer gets
The link opens the document in the browser — no account, no password, no download. The signer fills the required inputs, signs by drawing or typing, and gets their own copy from the same link when the document completes. Every step (opened, signed, sealed) lands in theaudit trailwith a timestamp, IP address, and device.
The signed PDF and audit trail
Download the signed PDF
/v1/documents/{id}/pdfStreams the sealed PDF as application/pdf once the document reaches signed. Called earlier it returns409 invalid_state. The hash, the signatures, and the signer metadata are embedded in the file itself, so a downloaded copy can be checked away from CalmSign.
curl https://api.usecalmsign.com/v1/documents/doc_7Kq2mR4xa9/pdf \
-H "Authorization: Bearer cs_live_9c2f4a7b1d0e8f36" \
-o nda-northwind.pdfDownload the audit trail
/v1/documents/{id}/audit-trailReturns the complete event log as JSON. SendAccept: application/pdf for the standalone certificate instead — the same document you would attach to a dispute.
{
"document_id": "doc_7Kq2mR4xa9",
"events": [
{ "type": "created", "at": "2026-08-18T10:12:44Z", "actor": "api", "ip": "203.0.113.24" },
{ "type": "sent", "at": "2026-08-18T10:15:02Z", "actor": "api", "ip": "203.0.113.24" },
{ "type": "opened", "at": "2026-08-18T14:28:19Z", "actor": "jordan@example.com", "ip": "82.132.13.37", "device": "Chrome / macOS" },
{ "type": "signed", "at": "2026-08-18T14:32:07Z", "actor": "jordan@example.com", "ip": "82.132.13.37", "device": "Chrome / macOS" },
{ "type": "sealed", "at": "2026-08-18T14:32:09Z", "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }
]
}Verifying the seal
Read the seal
/v1/documents/{id}/sealAt completion CalmSign computes a SHA-256 hash over an immutable snapshot of the document. This endpoint recomputes that hash from the stored snapshot and compares it:status is intact when they match and broken when they do not. Read more abouthow the seal works.
{
"document_id": "doc_7Kq2mR4xa9",
"algorithm": "sha256",
"value": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"sealed_at": "2026-08-18T14:32:09Z",
"checked_at": "2026-08-18T15:04:11Z",
"status": "intact",
"snapshot_url": "https://api.usecalmsign.com/v1/documents/doc_7Kq2mR4xa9/snapshot"
}Recompute the hash yourself
You do not have to take our word for it. Pull the snapshot bytes and hash them with any SHA-256 implementation — the digest must equal value above. A single changed character produces a completely different digest.
curl -sS https://api.usecalmsign.com/v1/documents/doc_7Kq2mR4xa9/snapshot \
-H "Authorization: Bearer cs_live_9c2f4a7b1d0e8f36" \
| shasum -a 256
# 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 -Webhooks
Rather than polling, register an HTTPS endpoint underSettings → Webhooks and CalmSign will POST each event as it happens. You get a signing secret (whsec_…) when you create the endpoint.
Event types
| Event | Sent when |
|---|---|
document.sent | A signing link has been issued and emailed to a signer. |
document.signed | The last signer signed. The document is sealed and final. |
document.declined | A signer declined to sign. No seal is produced. |
Payload shape
Every event has the same envelope: an id, a type, a creation timestamp, and adata object holding the document and, where the event concerns one, the signer.
{
"id": "evt_5Hn8xT2c",
"type": "document.signed",
"created_at": "2026-08-18T14:32:07Z",
"data": {
"document": {
"id": "doc_7Kq2mR4xa9",
"name": "Mutual Non-Disclosure Agreement",
"status": "signed",
"completed_at": "2026-08-18T14:32:07Z",
"seal": {
"algorithm": "sha256",
"value": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"status": "intact"
}
},
"signer": {
"id": "sgn_3Vb8tL",
"name": "Jordan Mitchell",
"email": "jordan@example.com"
}
}
}Reply 2xx within 10 seconds. Anything else is retried with exponential backoff for 24 hours, so handlers should be idempotent — key them onid, which is stable across retries.
Verifying the webhook signature
Each delivery carries a signature header: a timestamp and an HMAC-SHA256 oft + "." + rawBody, keyed with your signing secret.
CalmSign-Signature: t=1787063527,v1=6f1b0c9a4d2e8b73c05a1f9e2d4b8c7a05e3f61d9b2c4a780f13e5d6c8b9a204Recompute it over the raw request body — before any JSON parsing or re-serialising, which would change the bytes — and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody must be the exact bytes we POSTed — verify before JSON.parse.
export function isFromCalmSign(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
// Reject anything older than five minutes to kill replays.
const age = Math.floor(Date.now() / 1000) - Number(parts.t);
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac('sha256', secret)
.update(parts.t + '.' + rawBody)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}Errors
CalmSign uses conventional HTTP status codes. Every failure returns the same body: a stabletype to branch on, a human-readablemessage, the offendingparam where one applies, and arequest_id to quote atsupport.
{
"error": {
"type": "validation_failed",
"message": "A signature block needs a label.",
"param": "blocks[4].label",
"request_id": "req_2Fp6yN"
}
}| Status | Type | Cause |
|---|---|---|
400 | invalid_request | Malformed JSON, or a parameter of the wrong type. |
401 | unauthorized | Missing, malformed, or revoked API key. |
403 | plan_required | The key belongs to a workspace without API access. Upgrade to Business. |
404 | not_found | No document with that id in this workspace. |
409 | invalid_state | The action does not apply to the current status — sending an already-signed document, for example. |
422 | validation_failed | The request parsed but a field is unusable. The param key names the offender. |
429 | rate_limited | Too many requests. Wait for the number of seconds in Retry-After. |
500 | server_error | Something broke on our side. Safe to retry with the same idempotency key. |
Rate limits
120 requests per minute per API key. Every response carries the current budget; a429 also tells you how long to wait.
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 41
Retry-After: 41Limits apply to requests, not to documents: the Business plan has no monthly document cap, so creating and sending is bounded only by this rate. If a bulk import needs more headroom, write to hello@usecalmsign.combefore you start rather than after the 429s.