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 window
  • X-RateLimit-Remaining: remaining requests
  • X-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 a 400. Default en.
  • finalize: true creates a signable contract and returns its signUrl. false (the default) creates a draft that costs nothing until you finalize it with PATCH /contracts/:id.
  • signers: optional. A signer with an email gets a link of their own, sent to that address only: { label: "Client", email: "client@example.com" }, where label names 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. A title such 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. Add level: "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 with 400 and code: "QES_NOT_ENABLED". The shared signUrl keeps 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 in signers, never the link itself. A label that is no field label is refused with 400 and code: "UNKNOWN_ROLE" before anything is created. At most five sends per signer in 24 hours, after that 429 with code: "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, so y: 0 is the top edge and y: 90 is 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 label belong 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 by label signs here.
  • initials: the same signer initials here. It counts as a signature (default 10 x 4).
  • date: filled with the day the signer named by label signs. That label needs a signature field (default 18 x 3.3).
  • text: prints text as is, for example a place. No label needed (default 25 x 3).
  • input: the signer named by label types this text in the signing dialog, for example the place where they sign. text is the value the dialog starts with, optional: true allows 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 a 400.
  • 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 attachment
  • 409: not fully signed yet. The body carries code: "NOT_SIGNED" and the current status.
  • 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 the signUrl. On anything but a draft you get 409 with code: "NOT_A_DRAFT".
  • title and tags can be changed until the contract is fully signed; afterwards 409 with code: "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 answers 409 with code: "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 created
  • contract.signed: A signer submitted their signature
  • contract.completed: All required signatures collected
  • contract.deleted: A contract was deleted
  • contract.cancelled: an open contract was cancelled over POST /contracts/{id}/cancel. The signatures collected so far stay, nobody can sign it anymore
  • signer.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 7
  • X-CanUSign-Event: Event type (e.g. contract.signed)
  • X-CanUSign-Delivery: Unique delivery ID
  • User-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.

CodeStatusWhen
RATE_LIMITED429More than 60 requests per minute on one key, or more than five link sends to one signer in 24 hours.
LIMIT_REACHED403Monthly 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_REQUIRED403Any call with a key of an AppSumo tier 1 account: API, webhooks and MCP start at tier 2. The body adds requiredTier.
LIMIT_REACHED409POST /webhooks when the account already has five webhooks.
NOT_A_DRAFT409PATCH finalize on a contract that is not a draft.
CONTRACT_SIGNED409PATCH title or tags and cancel on a fully signed contract, DELETE once one signature exists.
NOT_SIGNED409GET /contracts/:id/pdf before every signature is in.
UNKNOWN_ROLE400A signer label that is not a signature field label.
INVALID_EMAIL400A signer without a valid e-mail address.
INVALID_LEVEL400A signer level other than "ses" or "qes".
QES_NOT_ENABLED400level "qes" on an account without qualified signatures.
QES_ROLE_TAKEN400A second role with level "qes" on the same contract.
ALREADY_SIGNED409A new address for a role that already signed over its link.
SEND_FAILED502The signing link could not be sent by e-mail.
INTERNAL_ERROR500Unexpected 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