API Documentation
Create and manage contracts programmatically with the CanUSign API.
Authentication
All API requests require authentication using a Bearer token. Create an API key in your Settings.
Authorization: Bearer canu_your_api_key_here
Rate Limiting
The API is rate limited to 60 requests per minute per API key.
Rate limit headers are included in all responses:
X-RateLimit-Limit- Max requests per windowX-RateLimit-Remaining- Remaining requestsX-RateLimit-Reset- Window reset time (ISO 8601)
Over the limit you get 429 with code: "RATE_LIMITED" and a Retry-After header in seconds.
Base URL
https://canusign.com/api/v1
Endpoints
/contractsCreate a new contract, either from HTML content or from your own PDF. The two forms differ in where the signature fields end up.
Request Body: HTML content
Signature fields are appended as a signature block below the content, in the order given. Coordinates are not accepted here: HTML flows, so there is no fixed page position to place a field at.
{
"title": "My Contract",
"content": "<p>Contract content in HTML...</p>",
"language": "en",
"signatureFields": [
{
"label": "Client"
},
{
"label": "Provider"
}
],
"signaturesCentered": false,
"tags": [
"business"
],
"finalize": true
}language- Language of the signing page, the e-mails and the audit certificate: de, en, es, fr, it, pt, nl. Default en.finalize-truecreates a signable contract and returns itssignUrl.false(the default) creates a draft that costs nothing until you finalize it withPATCH /contracts/:id.signers: optional. A signer with anemailgets a link of their own, sent to that address only:{ label: "Client", email: "client@example.com" }, wherelabelnames a signature field label. Over that link only the fields of this label can be signed, and the certificate records the address as link delivered to, apart from an address a signer merely types. The sharedsignUrlkeeps working for everyone else, so a contract can mix both: one party by delivered link, the other in the room. Links go out at once on a finalized contract, and for a draft or an unpaid contract as soon as it becomes signable (PATCH finalize, credit or payment). The response lists each link's delivery status insigners, never the link itself. A label that is no field label is refused with400andcode: "UNKNOWN_ROLE"before anything is created. At most five sends per signer in 24 hours, after that429withcode: "RATE_LIMITED".
Request Body: your own PDF
Pass the PDF as base64 in document.pdf (raw or as a data:application/pdf;base64, URL, up to 3 MB) and place each field with coordinates. At least one field is required: a PDF without one is a page nobody can sign.
{
"title": "Order Confirmation 4711",
"document": {
"name": "order-4711.pdf",
"pdf": "JVBERi0xLjQK..."
},
"signatureFields": [
{
"label": "Customer",
"page": 2,
"x": 10,
"y": 78,
"width": 25,
"height": 10
},
{
"label": "Sunrise Sales",
"page": 2,
"x": 60,
"y": 78
}
],
"finalize": true
}Coordinate system
page- 1-based page number of the PDF (default 1). Must not exceed the page count.x,y- Top-left corner of the field box, in percent of the page width and height. Origin is the top-left corner of the page, soy: 0is the top edge andy: 90is near the bottom. Percentages make the placement independent of the page size (A4, Letter).width,height- Box size in percent of the page (default 25 x 10). The whole box must lie within the page.- Fields with the same
labelbelong to the same signer: one drawing fills all of them, for example initials on every page.
A field with coordinates but no document, or a box outside the page, is rejected with 400 and a list of the offending fields in details.
Response
{
"success": true,
"contract": {
"id": "clx123...",
"token": "ABC123XY",
"title": "My Contract",
"status": "pending",
"language": "en",
"signUrl": "https://canusign.com/sign/ABC123XY",
"requiresPayment": false,
"signers": [
{
"role": "Client",
"email": "client@example.com",
"status": "sent",
"sentAt": "2024-01-15T10:30:01Z",
"sendCount": 1,
"openedAt": null,
"signedAt": null
}
],
"createdAt": "2024-01-15T10:30:00Z"
}
}/contractsList all your contracts.
Query Parameters
status- Filter by status: draft, pending, pending_payment, fully_signed. Anything else is a400.limit- Results per page (default: 50, max: 100)offset- Pagination offset
Response
{
"contracts": [
{
"id": "clx123...",
"token": "ABC123XY",
"title": "My Contract",
"status": "pending",
"signatures": [],
"signers": [],
"createdAt": "2024-01-15T10:30:00Z"
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"hasMore": false
}
}/contracts/:idGet contract details by ID or token. Includes statusUrl, the signatures so far, and pdfUrl, which is null until the contract is fully signed. Each signature says whether it came in over a delivered link (deliveredLink), and signers lists the delivered links with their status: created, sent, opened or signed.
publicId is the document ID printed on every page of the signed PDF, and verifyUrl the public page where anyone can compare a PDF with the stored copy (null for drafts). finalPdf.sha256 is the fingerprint of the stored PDF with its DigiCert timestamp: every download returns exactly these bytes. It is null until the PDF was built once.
/contracts/:id/pdfDownload the signed PDF: the contract or your document with all signatures embedded, any attachments, and the audit certificate. Authenticated with your API key like every other endpoint; the contract must belong to the key's account.
200-application/pdf, sent as an attachment409- Not fully signed yet. The body carriescode: "NOT_SIGNED"and the currentstatus.404- Unknown ID, or the contract belongs to another account
The usual flow: subscribe a webhook to contract.completed, then fetch the pdfUrl from its payload. Polling GET /contracts/:id until pdfUrl is set works too.
/contracts/:idRename or retag a contract, or finalize a draft. All fields are optional, at least one is required.
{
"title": "Order Confirmation 4711",
"tags": [
"orders"
],
"finalize": true
}finalize: true- Turns a draft into a signable contract, subject to the same plan rules as creating it finalized. The response carries thesignUrl. On anything but a draft you get409withcode: "NOT_A_DRAFT".titleandtagscan be changed until the contract is fully signed; afterwards409withcode: "CONTRACT_SIGNED".signers: same shape as on create. Adds or changes delivered links per label; a new address replaces the old link. A role that already signed over its link answers409withcode: "ALREADY_SIGNED".
/contracts/:idDelete a contract nobody has signed yet. As soon as one signature exists the contract is a record and stays: 409 with code: "CONTRACT_SIGNED".
/contracts/from-templateList all available templates with their variables. Use this to discover which templates and variable fields are available before creating a contract. signerRoles lists the labels of the signature fields, which signers[].label may name.
Query Parameters
language- Template language (de, en, es, fr, it, pt, nl). Default: en
Response
{
"templates": [
{
"id": "dpa",
"name": "Data Processing Agreement (EN)",
"category": "legal",
"icon": "🔐",
"availableLanguages": [
"de",
"en",
"es",
"..."
],
"variables": [
{
"id": "controller_name",
"label": "Controller Name"
},
{
"id": "processor_name",
"label": "Processor Name"
}
],
"signerRoles": [
"Controller",
"Processor"
],
"hasContent": true
}
],
"language": "en",
"total": 45
}/contracts/from-templateCreate a contract from a pre-built template. No need to provide HTML — just choose a template, language, and fill in the variables.
Request Body
{
"template": "dpa",
"language": "en",
"variables": {
"controller_name": "Acme Corp",
"processor_name": "Cloud Services GmbH",
"controller_address": "123 Main St, Berlin",
"processor_address": "456 Tech Ave, Munich"
},
"title": "DPA - Acme & Cloud Services",
"tags": [
"dpa",
"gdpr"
],
"signers": [
{
"label": "Processor",
"email": "legal@cloud-services.example"
}
],
"finalize": true
}signers works as on POST /contracts: each signer with an email gets a link of their own. The label must be one of the template's signerRoles, otherwise the request is refused with 400 before anything is created.
Response
{
"success": true,
"contract": {
"id": "clx456...",
"token": "DEF789AB",
"title": "DPA - Acme & Cloud Services",
"status": "pending",
"signUrl": "https://canusign.com/sign/DEF789AB",
"signers": [
{
"role": "Processor",
"email": "legal@cloud-services.example",
"status": "sent"
}
],
"createdAt": "2025-01-15T10:30:00Z"
},
"template": {
"id": "dpa",
"language": "en",
"variablesFilled": 4,
"variablesAvailable": 12
}
}Webhooks
Overview
Webhooks allow you to receive real-time HTTP notifications when events happen on your contracts. Instead of polling the API, register a URL and we'll send a POST request with event data whenever something changes.
Available Events
contract.created— A new contract was createdcontract.signed— A signer submitted their signaturecontract.completed— All required signatures collectedcontract.deleted— A contract was deletedsigner.invited: a signing link went out to one signer's address (role,email,sentAt)
Payload Format
{
"id": "del_abc123...",
"event": "contract.signed",
"created_at": "2025-01-15T10:30:00Z",
"data": {
"id": "clx123...",
"token": "ABC123XY",
"title": "Service Agreement",
"fieldId": "sig-1",
"signerName": "John Doe"
}
}contract.completed carries the download link instead of the signer:
{
"id": "del_def456...",
"event": "contract.completed",
"created_at": "2025-01-15T11:02:00Z",
"data": {
"id": "clx123...",
"token": "ABC123XY",
"title": "Service Agreement",
"signatureCount": 2,
"pdfUrl": "https://canusign.com/api/v1/contracts/clx123.../pdf",
"publicId": "7E2BBDC643834517",
"verifyUrl": "https://canusign.com/verify/7E2BBDC643834517"
}
}Delivery
Your endpoint has five seconds to answer with a 2xx. Anything else counts as a failure, and we try again: once after three seconds, then after 10 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. Seven attempts over roughly 33 hours, so a bad deploy on your side does not cost you the event. After the last one we stop, and you can pick the state up from GET /contracts/:id.
Every attempt of one event carries the same X-CanUSign-Delivery id and a fresh signature. De-duplicate on that id and treat your handler as repeatable. The URL must be public HTTPS; loopback and private addresses are refused. Recent deliveries with status code, attempt and duration are listed under GET /webhooks/:id.
HTTP Headers
X-CanUSign-Signature-V2- the signature to verify:t=<unix seconds>,v1=<hex>. The timestamp is part of what is signed, so a captured delivery cannot be replayed later.X-CanUSign-Signature- the older scheme over the body alone,sha256=<hex>. Still sent for receivers built before 07.09.2026, but it cannot tell a replay from a fresh delivery. Prefer V2.X-CanUSign-Attempt- 1 for the first try, up to 7X-CanUSign-Event— Event type (e.g.contract.signed)X-CanUSign-Delivery— Unique delivery IDUser-Agent—CanUSign-Webhook/1.0
Signature Verification
Every webhook delivery is signed with your webhook secret using HMAC-SHA256. Always verify the signature before processing events.
Sign the raw body, byte for byte
The signature covers the exact bytes we sent. Read the body as text before any JSON parsing, and never rebuild it: parsing and re-serializing changes the bytes, and the signature stops matching. A single non-ASCII character in a title is enough: Python's json.dumps turns an em dash into \u2014, while we send it as UTF-8. In Next.js use await req.text(), in Express express.raw(), in Flask request.get_data(). Parse the JSON only after the signature has checked out.
Node.js
const crypto = require('crypto');
// payload = the raw request body as a string, e.g. await req.text()
// header = the X-CanUSign-Signature-V2 header, "t=<seconds>,v1=<hex>"
function verifyWebhook(payload, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('='))
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.' + payload)
.digest('hex');
const a = Buffer.from(parts.v1 ?? '', 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hmac, hashlib, time
# payload = the raw request body as bytes, e.g. request.get_data()
# header = the X-CanUSign-Signature-V2 header, "t=<seconds>,v1=<hex>"
def verify_webhook(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.strip().split('=', 1) for p in header.split(','))
try:
age = abs(time.time() - int(parts['t']))
except (KeyError, ValueError):
return False
if age > tolerance:
return False
signed = parts['t'].encode() + b'.' + payload
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get('v1', ''), expected)Webhook Endpoints
/webhooksList all your webhooks.
/webhooksCreate a new webhook endpoint, up to five per account. Returns 201.
Request Body
{
"url": "https://your-server.com/webhooks",
"events": [
"contract.signed",
"contract.completed"
]
}Response
{
"success": true,
"webhook": {
"id": "clx789...",
"url": "https://your-server.com/webhooks",
"events": [
"contract.signed",
"contract.completed"
],
"active": true,
"secret": "whsec_a1b2c3d4...",
"createdAt": "2025-01-15T10:30:00Z"
}
}The secret is only returned on creation. Store it securely to verify webhook signatures.
/webhooks/:idUpdate webhook URL, events, or active status. All fields are optional.
{
"url": "https://new-server.com/hooks",
"events": [
"contract.completed"
],
"active": false
}/webhooks/:idDelete a webhook.
/webhooks/:idGet webhook details plus its last 20 deliveries (event, status code, attempt, duration), the place to look when an event did not arrive.
Examples (cURL)
Create contract with custom HTML
curl -X POST https://canusign.com/api/v1/contracts \
-H "Authorization: Bearer canu_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Service Agreement",
"content": "<h1>Service Agreement</h1><p>Terms...</p>",
"finalize": true
}'Create contract from your own PDF with placed fields
PDF_B64=$(base64 < order-4711.pdf | tr -d '\n')
curl -X POST https://canusign.com/api/v1/contracts \
-H "Authorization: Bearer canu_your_api_key" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Order Confirmation 4711\",
\"document\": { \"name\": \"order-4711.pdf\", \"pdf\": \"$PDF_B64\" },
\"signatureFields\": [
{ \"label\": \"Customer\", \"page\": 2, \"x\": 10, \"y\": 78 },
{ \"label\": \"Vendor\", \"page\": 2, \"x\": 60, \"y\": 78 }
],
\"finalize\": true
}"Download the signed PDF
curl -o signed.pdf https://canusign.com/api/v1/contracts/clx123.../pdf \ -H "Authorization: Bearer canu_your_api_key"
Create contract from template
curl -X POST https://canusign.com/api/v1/contracts/from-template \
-H "Authorization: Bearer canu_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"template": "dpa",
"language": "en",
"variables": {
"controller_name": "Acme Corp",
"processor_name": "Cloud Services GmbH"
},
"title": "DPA - Acme & Cloud",
"finalize": true
}'List available templates
curl https://canusign.com/api/v1/contracts/from-template?language=en \ -H "Authorization: Bearer canu_your_api_key"
Claude Code / MCP Integration
Use CanUSign directly from Claude Code with our MCP server. Create contracts just by describing them in natural language.
1. Install the MCP Server
npx canusign-mcp-server
2. Configure Claude Code
Add to your ~/.claude/claude_desktop_config.json:
{
"mcpServers": {
"canusign": {
"command": "npx",
"args": [
"canusign-mcp-server"
],
"env": {
"CANUSIGN_API_KEY": "canu_your_api_key_here"
}
}
}
}3. Use it!
Ask Claude to create contracts for you:
“Create a service agreement between Acme Corp and John Doe for web development at $5000”
“Create a DPA (Data Processing Agreement) between Acme Corp as controller and Cloud GmbH as processor for customer data processing”
“Send the signing link for the Client to anna@acme.example, I will sign as Provider on my laptop”
The MCP server uses the /contracts/from-template endpoint to create contracts from pre-built templates with pre-filled variables. Its tools create_contract, create_contract_from_template and update_contract take signers to e-mail links to individual signers.
Questions? Contact support