API reference
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
OpenAPI 3.1 description: https://canusign.com/openapi.json
Endpoints
POST/contracts
Create 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. Any other value is a400. 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. Atitlesuch as"Managing Director"presets what the signer signs as: it fills the title in the sign dialog and appears in the stamp under the name, and the signer can still change it. Addlevel: "qes"for a qualified electronic signature: the signer identifies once with the provider and signs last, after every other field. Qualified signatures are enabled per account; without that the request is refused with400andcode: "QES_NOT_ENABLED". 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".Qualified signature (QES): in preparation, not yet available. Do you need it? Let us know.
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.
Field types
type is optional and defaults to signature. The other types need a document.
signature: the signer named bylabelsigns here.initials: the same signer initials here. It counts as a signature (default 10 x 4).date: filled with the day the signer named bylabelsigns. That label needs a signature field (default 18 x 3.3).text: printstextas is, for example a place. Nolabelneeded (default 25 x 3).input: the signer named bylabeltypes this text in the signing dialog, for example the place where they sign.textis the value the dialog starts with,optional: trueallows it empty. At most 200 characters, printed like text. That label needs a signature field (default 25 x 3).
{
"signatureFields": [
{
"type": "input",
"label": "Customer",
"text": "Hamburg",
"page": 2,
"x": 10,
"y": 70,
"width": 15
},
{
"type": "date",
"label": "Customer",
"page": 2,
"x": 26,
"y": 70
},
{
"label": "Customer",
"page": 2,
"x": 10,
"y": 74,
"width": 30,
"height": 8
},
{
"type": "initials",
"label": "Customer",
"page": 1,
"x": 85,
"y": 92
}
]
}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"
}
}GET/contracts
List all your contracts.
Query Parameters
status: filter by status: draft, pending, pending_payment, fully_signed, cancelled. 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
}
}GET/contracts/:id
Get 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.
GET/contracts/:id/pdf
Download 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.
PATCH/contracts/:id
Rename 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".
DELETE/contracts/:id
Delete a contract nobody has signed yet. As soon as one signature exists the contract is a record and stays: 409 with code: "CONTRACT_SIGNED".
POST/contracts/:id/cancel
Cancel a contract that is still open, also one with first signatures. The status becomes cancelled, the signatures collected so far stay on record and no signing link works anymore. A fully signed contract answers 409 with code: "CONTRACT_SIGNED"; cancelling twice answers 200 again.
410/contracts/from-template
Templates were removed on 20 September 2026. Both GET and POST on this path answer 410 with error: "gone". Create contracts from your own PDF or HTML with POST /contracts.
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 deletedcontract.cancelled: an open contract was cancelled overPOST /contracts/{id}/cancel. The signatures collected so far stay, nobody can sign it anymoresigner.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
GET/webhooks
List all your webhooks.
POST/webhooks
Create 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.
PATCH/webhooks/:id
Update webhook URL, events, or active status. All fields are optional.
{
"url": "https://new-server.com/hooks",
"events": [
"contract.completed"
],
"active": false
}DELETE/webhooks/:id
Delete a webhook.
GET/webhooks/:id
Get webhook details plus its last 20 deliveries (event, status code, attempt, duration), the place to look when an event did not arrive.
Error codes
Errors answer with a JSON body carrying error, a readable message. The errors below also carry code. Validation errors without a code answer 400, a missing or invalid key 401, an unknown ID 404.
| Code | Status | When |
|---|---|---|
RATE_LIMITED | 429 | More than 60 requests per minute on one key, or more than five link sends to one signer in 24 hours. |
LIMIT_REACHED | 403 | Monthly document limit of your plan reached and no credit left, on POST /contracts with finalize or on PATCH finalize. With a credit the contract is created as pending_payment instead (requiresPayment: true); the owner redeems the credit on the payment page of the contract, https://canusign.com/payment?contractId={id}, which the dashboard links from the contract. The body adds documentsUsed and documentsLimit. |
APPSUMO_TIER_REQUIRED | 403 | Any call with a key of an AppSumo tier 1 account: API, webhooks and MCP start at tier 2. The body adds requiredTier. |
LIMIT_REACHED | 409 | POST /webhooks when the account already has five webhooks. |
NOT_A_DRAFT | 409 | PATCH finalize on a contract that is not a draft. |
CONTRACT_SIGNED | 409 | PATCH title or tags and cancel on a fully signed contract, DELETE once one signature exists. |
NOT_SIGNED | 409 | GET /contracts/:id/pdf before every signature is in. |
UNKNOWN_ROLE | 400 | A signer label that is not a signature field label. |
INVALID_EMAIL | 400 | A signer without a valid e-mail address. |
INVALID_LEVEL | 400 | A signer level other than "ses" or "qes". |
QES_NOT_ENABLED | 400 | level "qes" on an account without qualified signatures. |
QES_ROLE_TAKEN | 400 | A second role with level "qes" on the same contract. |
ALREADY_SIGNED | 409 | A new address for a role that already signed over its link. |
SEND_FAILED | 502 | The signing link could not be sent by e-mail. |
INTERNAL_ERROR | 500 | Unexpected server error. |
A signer error on POST /contracts that shows up after the contract was created also returns contract with its id, token and status.
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"
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”
“Send the signing link for the Client to anna@acme.example, I will sign as Provider on my laptop”
The MCP server writes the contract HTML itself and sends it to POST /contracts. Its tools create_contract and update_contract take signers to e-mail links to individual signers.
Questions? Contact support