Section 1
Getting Started
Platform Overview
ctSignature is a multi-tenant, API-first document signing platform. It lets you send PDFs for legally binding electronic signatures, track signer progress, and store signed documents with a full audit trail.
Key capabilities:
- Single and multi-signer documents — sequential or parallel signing order
- Reusable templates — pre-place fields once, send to many recipients
- Embedded signing — host the signing experience inside your own app via iframe
- Webhooks — get notified in real time when documents are viewed, signed, or completed
- ESIGN/UETA compliance — consent capture, OTP verification, device fingerprinting, audit log, and certificate of completion
- White-label branding — custom logo, colors, and brand name on signing pages
- Address book — save frequent signers for quick reuse
Base URLs & Environments
| Environment | Base URL | Notes |
|---|---|---|
| Production | https://ctsign.io |
Default for hosted ctSignature. Self-hosted deployments set their own via DocumentSigning:ProductionBaseUrl. |
| Development | http://localhost:8080 |
Test mode enabled, email sending skipped |
All API paths in this manual are relative to the base URL.
Quick Start (5 Minutes)
Before you can call the API, you need an account and an API key. There’s no API to create either — both require the browser. Once you have a key, every step below can run from your backend.
- Sign up. Go to ctsign.io → or create a new account. Fill in Company Name, Your Name, Email → Create Account.
- Set your password. Click Continue to password setup (also emailed to you). You’ll be handed off to ctOneAuth — set a password, verify your email, and you’ll bounce back to the dashboard already signed in.
- Create an API key. In the dashboard sidebar, click Developer → API Keys tab → type a label (e.g. Production) → Create Key. Copy the
ctds_live_…value now — it’s shown once. - (Optional) Register a webhook. Developer → Webhooks tab → Add Webhook. Paste your receiving URL, pick events. Copy the signing secret (shown once).
- (Optional) Allow iframe embedding. Developer → Embedded Signing tab → toggle on, add your app’s origin (e.g.
https://app.yourbusiness.com), save. Required only if you embed the signing/placement pages in your own UI.
Once you’ve copied the ctds_live_… key, the rest of this guide is API calls only.
With your API key in hand, here’s the canonical document-signing flow:
export CTDS_KEY="ctds_live_aBcDeFgHiJkLm..."
If you prefer to skip the browser entirely and provision the account via API, see POST /api/auth/register in Section 2.2 — it returns a setupUrl the user opens once to set a password, then a subsequent GET /api/auth/oidc/login yields a JWT, after which you can call POST /api/dashboard/api-keys to mint a key programmatically. Most integrators just use the UI one-time setup above.
POST /api/v1/documents Authorization: Bearer ctds_live_aBcDeFgHiJkLm... Content-Type: multipart/form-data RecipientName: John Smith RecipientEmail: john@example.com File: contract.pdf
You get back a placementUrl (to position signature fields) and a signingUrl (to send to the signer).
Skip placement entirely by using a template with fields already saved — POST /api/v1/templates/{id}/send mails the signer directly.
Open the placementUrl in a browser. Drag and drop the signature, printed name, date, and initials fields onto the document. Click Save & Send.
The signer receives an email with a link (or you send them the signingUrl directly). They review the document, give consent, type their signature, and submit.
GET /api/documents/signed/{documentId}?token=<secureToken>
Uses the secure token from step 2 — no Authorization header needed. The signed PDF has a Certificate of Completion appended. If you prefer JWT auth, use GET /api/dashboard/documents/{documentId}/download instead.
Section 2
Authentication
ctSignature supports four types of authentication, each for a different purpose.
API Keys (Programmatic Access)
API keys are the primary way to call the REST API from your backend code. Each key is tied to a single tenant and gives full read/write access to that tenant's data.
Key Format
Keys follow the pattern ctds_<random-characters>. Example:
ctds_aB3cDeFgH1iJkLmN2oPqRsT3uVwXy
How to Send
Include the key in the Authorization header using the Bearer scheme:
Authorization: Bearer ctds_aB3cDeFgH1iJkLmN2oPqRsT3uVwXy
An X-Api-Key header carrying the same key is also accepted, if that suits your HTTP client better:
X-Api-Key: ctds_aB3cDeFgH1iJkLmN2oPqRsT3uVwXy
Generating a Key
POST /api/dashboard/api-keys
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"name": "Production Server" // optional label
}
// Response
{
"id": 1,
"key": "ctds_aB3cDeFgH1iJkLmN2oPqRsT3uVwXy", // shown only once!
"prefix": "ctds_aB3",
"name": "Production Server",
"createdDate": "2026-04-17T10:00:00Z"
}
Revoking a Key
DELETE /api/dashboard/api-keys/{keyId}
Authorization: Bearer <jwt-token>
Endpoints That Accept API Keys
| Path Prefix | Description |
|---|---|
/api/v1/documents | Create, list, get, delete documents |
/api/v1/templates | Create, send, batch-send templates |
/api/v1/recipients | Manage address book |
/api/v1/webhooks | Manage webhooks |
JWT Bearer Tokens (Dashboard & User Sessions)
JWT tokens are used by the web dashboard and by applications that need user-level authentication (for example, if your app lets users log in to manage their own signing settings).
Getting a Token
Identity is managed by ctOneAuth (OIDC). ctSignature does not store passwords.
Registration creates the local tenant and provisions the org in ctOneAuth in a single call;
sign-in always goes through the OIDC flow at GET /api/auth/oidc/login, which
redirects to ctOneAuth and finishes by minting a JWT for the dashboard.
Register a New Account
POST /api/auth/register
Content-Type: application/json
{
"companyName": "Acme Corp",
"name": "Jane Developer",
"email": "jane@acme.com"
}
// Response — no password is set here; user finishes setup on ctOneAuth.
{
"setupUrl": "https://ctoneauth.example/setup?token=...",
"tenant": {
"id": 1,
"companyName": "Acme Corp",
"email": "jane@acme.com",
"isTrial": true
},
"user": {
"id": 1,
"name": "Jane Developer",
"role": "Admin"
}
}
Sign In
GET /api/auth/oidc/login // 302 → ctOneAuth /oauth2/authorize → /signin-oidc → /api/auth/oidc/complete // On success, a JWT is delivered to /dashboard/oidc-complete.html via URL fragment.
How to Send
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Token Details
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Lifetime | 24 hours (configurable) |
| Issuer | Jwt:Issuer setting |
| Audience | Jwt:Audience setting |
JWT Claims
| Claim | Description |
|---|---|
sub | Tenant ID (number as string) |
user_id | Tenant user ID (if multi-user tenant) |
role | User role: Admin, User, or Reviewer |
email | User's email address |
iat | Issued-at timestamp |
exp | Expiration timestamp |
Check Current User
GET /api/auth/me
Authorization: Bearer <jwt-token>
// Response
{
"tenant": { "id": 1, "companyName": "Acme Corp", ... },
"user": { "id": 1, "name": "Jane Developer", "role": "Admin" }
}
Platform Admin Tokens
Platform admin tokens give access to system-wide management endpoints. These credentials are set in the server configuration, not in the database.
POST /api/admin/auth/login
Content-Type: application/json
{
"email": "<PlatformAdmin:Email from config>",
"password": "<PlatformAdmin:Password from config>"
}
// Response
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"email": "admin@ctsignature.com",
"role": "platform_admin"
}
Use this token with /api/admin/* endpoints only.
Signing & Placement Tokens
These are single-use, document-specific tokens embedded in URLs. They do not require any auth header — the token in the URL is the authentication.
| Token Type | Used For | URL Pattern | Expires |
|---|---|---|---|
| Placement Token | Positioning signature fields on the PDF | /sign/place/{token} |
Same as document expiration (default 72 hours) |
| Secure Token (Signing) | Signing the document | /sign/document/{token} |
Same as document expiration (default 72 hours) |
For multi-signer documents, each signer gets their own unique secure token and signing URL.
Section 3
Documents API
The Documents API lets you create, track, and manage documents for signing. All endpoints below use API key authentication unless noted otherwise.
Create a Document (Single Signer)
Upload a PDF or Word document and specify who should sign it. Word files (.doc/.docx) are converted to PDF on upload — the stored, placed, and signed document is always a PDF.
Request
Content-Type: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
File | file | Yes* | PDF or Word file (max 10 MB). Word files are converted to PDF. *Provide either File or Files. |
Files | file (repeatable) | Yes* | Multiple PDF/Word files, merged into one document in the order sent (max 10 files). |
RecipientName | string | Yes | Signer's full name |
RecipientEmail | string | Yes | Signer's email address |
ExpirationHours | integer | No | Hours until signing link expires (default: 72) |
NotificationEmail | string | No | Email address that receives the "document signed" notifications. If omitted, no notification is sent for API-created documents (there is no fallback to the tenant-level email). |
ReplyToEmail | string | No | Where a signer's reply to this document's emails is delivered. See Where signer replies go. |
SendInvite | boolean | No | Whether ctSignature emails the signer their invite. Omitted keeps this path's default. See Who Emails the Signer. |
RemindersEnabled | boolean | No | Default true. ctSignature emails the signer a daily reminder until signed (max 4); each reminder renews the link's expiry and notifies you. Pass false if your app handles all signer communication. |
FieldsJson | JSON array (string) | No | Inline field placement — skips the placement step; the document is created already sent. See "Inline field placement" below. |
Example
curl -X POST https://ctsign.io/api/v1/documents \ -H "Authorization: Bearer ctds_yourApiKey" \ -F "RecipientName=John Smith" \ -F "RecipientEmail=john@example.com" \ -F "ExpirationHours=48" \ -F "NotificationEmail=sender@acme.com" \ -F "File=@contract.pdf"
Response (200 OK)
{
"documentId": 42,
"placementUrl": "https://ctsign.io/sign/place/abc123...",
"signingUrl": "https://ctsign.io/sign/document/xyz789...",
"placementToken": "abc123...",
"secureToken": "xyz789...",
"expirationDate": "2026-05-19T10:00:00Z"
}
placementUrl to position signature fields on the PDF. Every field type can be placed multiple times — signatures, initials, dates, text boxes — and the signer completes each one individually. Once placed, the signing link becomes active and the signer receives an email.
Inline field placement (FieldsJson)
Pass a JSON array of field objects to place fields at creation time and skip the placement page entirely. At least one signature field is required; the response then has "skippedPlacement": true and no placementUrl.
-F 'FieldsJson=[
{"type":"signature","x":100,"y":620,"width":150,"height":32,"page":2},
{"type":"date","x":280,"y":635,"width":100,"height":20,"page":2},
{"type":"initials","x":480,"y":700,"width":100,"height":24,"page":1},
{"type":"text","x":100,"y":200,"width":180,"height":28,"page":1,"isRequired":false},
{"type":"sender_text","x":100,"y":100,"width":220,"height":30,"page":1,
"value":"Please initial every page and sign on page 2."}
]'
Coordinates are PDF points with the origin at the top-left of each page; page is 1-based. Field types: signature, initials, date, datetime, text (signer fills in), name (auto: printed name), stamp (auto: digital stamp block), and sender_text (your own text, stamped into the PDF — requires value). All types are repeatable; isRequired defaults to true and signatures are always required.
Create a Multi-Signer Document
Create a document that requires two or more signers.
Request
Content-Type: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
File | file | Yes* | PDF or Word file (max 10 MB). Word files are converted to PDF. *Provide either File or Files. |
Files | file (repeatable) | Yes* | Multiple PDF/Word files, merged into one document in the order sent (max 10 files). |
Signers | JSON array | Yes | Array of signer objects (see below), sent as a JSON-encoded string. Indexed form fields (Signers[0].SignerName, ...) are also accepted. |
WorkflowType | string | Yes | sequential or parallel |
ExpirationHours | integer | No | Hours until expiration (default: 72) |
NotificationEmail | string | No | Email address that receives the "document signed" notifications. If omitted, no notification is sent for API-created documents (there is no fallback to the tenant-level email). |
ReplyToEmail | string | No | One value for the whole document — it covers every signer. See Where signer replies go. |
RemindersEnabled | boolean | No | Default true. Daily reminders to each pending signer until they sign (max 4 per signer; sequential workflows only remind whoever's turn it is). |
Signer Object
| Field | Type | Required | Description |
|---|---|---|---|
SignerName | string | Yes | Signer's full name |
SignerEmail | string | Yes | Signer's email |
SignerRole | string | No | Role label (e.g., "Manager", "Legal") |
SignOrder | integer | No | Signing order (for sequential workflow) |
Example
curl -X POST https://ctsign.io/api/v1/documents/multi-signer \
-H "Authorization: Bearer ctds_yourApiKey" \
-F "WorkflowType=sequential" \
-F 'Signers=[{"SignerName":"Alice","SignerEmail":"alice@acme.com","SignOrder":1},{"SignerName":"Bob","SignerEmail":"bob@acme.com","SignOrder":2}]' \
-F "File=@agreement.pdf"
Response (200 OK)
{
"documentId": 43,
"workflowType": "sequential",
"placementUrl": "https://ctsign.io/sign/place/abc...",
"signers": [
{
"signerId": 1,
"signerName": "Alice",
"signerEmail": "alice@acme.com",
"signOrder": 1,
"status": "pending",
"signingUrl": "https://ctsign.io/sign/document/token_alice..."
},
{
"signerId": 2,
"signerName": "Bob",
"signerEmail": "bob@acme.com",
"signOrder": 2,
"status": "pending",
"signingUrl": "https://ctsign.io/sign/document/token_bob..."
}
]
}
Parallel: all signers can sign at the same time. Everyone gets their link immediately.
Where Signer Replies Go
Signers reply to signing emails — with questions, corrections, or "is this legitimate?". By default those replies reach ctSignature's own notification mailbox, which is almost never who the signer meant to write to.
ReplyToEmail sets the Reply-To header on every signer-facing email for that document: the signing request, the daily reminders, the next-signer notice, and the verification-code email. Point it at whoever should field the signer's question.
curl -X POST https://ctsign.io/api/v1/documents \ -H "Authorization: Bearer ctds_yourApiKey" \ -F "RecipientName=John Smith" \ -F "RecipientEmail=john@example.com" \ -F "ReplyToEmail=casemanager@youragency.com" \ -F "File=@contract.pdf"
Binding. A plain string form field on multipart requests (ReplyToEmail=...), a plain string property on the JSON template endpoints ("replyToEmail": "..."). Not indexed, not JSON-encoded, and one value per document — it covers every signer on a multi-signer document. Invalid addresses are rejected with 400.
Resolution order
When ReplyToEmail is omitted, the first match wins:
ReplyToEmailon the document- the email of the signed-in user who created it (dashboard sends)
- the tenant-wide Reply-To Address (Dashboard → Branding, or
replyToEmailon the partner tenant API) - the platform default — replies come back to ctSignature
ctds_ key is stored with no sender user, by design. It is not inferred from the key’s creator or from the tenant admin. Either send ReplyToEmail on every create call, or set the tenant-wide default once. Doing both is fine; per-document wins.
Reminders, the next-signer notice and the OTP email are always ctSignature’s, so ReplyToEmail always covers those. The invite is the one that varies — see Who Emails the Signer.
Who Emails the Signer Their Invite
Historically this depended on whether a placement step happened, which is not obvious from the outside:
| Path | Placement | Invite emailed by (SendInvite omitted) |
|---|---|---|
POST /documents, no FieldsJson | human opens placementUrl | ctSignature, at placement time |
POST /documents with FieldsJson | skipped | nobody — you deliver signingUrl |
POST /documents/multi-signer | human opens placementUrl | ctSignature, at placement time |
templates/{id}/send — single-signer | skipped (pre-placed) | nobody |
templates/{id}/send — multi-signer | skipped (pre-placed) | ctSignature |
templates/{id}/batch-send — single-signer | skipped | nobody |
templates/{id}/batch-send — multi-signer | skipped | ctSignature |
| Dashboard send | either | ctSignature |
SendInvite overrides all of that:
| Value | Behaviour |
|---|---|
| omitted (default) | the per-path behaviour above — nothing changes for existing integrations |
true | ctSignature always emails the signer, on every path |
false | ctSignature never emails the signer — you deliver signingUrl |
curl -X POST https://ctsign.io/api/v1/documents \
-H "Authorization: Bearer ctds_yourApiKey" \
-F "RecipientName=Sarah Johnson" \
-F "RecipientEmail=sarah@example.com" \
-F "ReplyToEmail=casemanager@youragency.com" \
-F 'FieldsJson=[{"type":"signature","x":100,"y":620,"width":150,"height":32,"page":1}]' \
-F "SendInvite=true" \
-F "File=@contract.pdf"
Binding matches ReplyToEmail: plain form field on multipart, "sendInvite": true on the JSON template endpoints. It is stored on the document, so it still applies when the invite is sent later at placement time.
truesigningUrl themselves. Had the invite simply been switched on, every one of those signers would get two emails — the integration’s and ctSignature’s. If you adopt SendInvite=true, delete your own invite send in the same change. Conversely SendInvite=false does not stop reminders — pass RemindersEnabled=false too if you own all signer communication.
Owner-facing notifications — "document signed", "all signatures complete", "signing link expired", "reminder sent" — are unaffected. They already arrive with you, so their replies stay with ctSignature.
List Documents
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | (all) | Filter: pending, sent, signed, expired |
includeArchived | boolean | false | Archived documents are hidden from the list by default; pass true to include them. Each item carries archivedAt (null unless archived) so you can tell them apart. See Archive a Document. |
page | integer | 1 | Page number |
pageSize | integer | 20 | Results per page (1–100) |
Response (200)
{
"items": [
{
"id": 42,
"originalFilename": "contract.pdf",
"recipientName": "John Smith",
"recipientEmail": "john@example.com",
"status": "signed",
"expirationDate": "2026-05-19T10:00:00Z",
"createdDate": "2026-05-17T10:00:00Z",
"signedDate": "2026-05-17T14:30:00Z",
"isExpired": false,
"hasPlacement": true
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1,
"hasMore": false
}
expired filterstatus on a document is never literally "expired" — expiration is derived from the timestamp. The list endpoint accepts ?status=expired as a convenience filter (returns unsigned documents past their expirationDate), but each item still reports its underlying status (e.g. sent) along with isExpired: true.
Get Document Details
Returns the document’s status, signer audit entries, and any required-initials field placements.
Response (200)
{
"id": 42,
"originalFilename": "contract.pdf",
"recipientName": "John Smith",
"recipientEmail": "john@example.com",
"status": "signed",
"workflowType": "single",
"expirationDate": "2026-05-19T10:00:00Z",
"createdDate": "2026-05-17T10:00:00Z",
"modifiedDate": "2026-05-17T14:30:00Z",
"signedDate": "2026-05-17T14:30:00Z",
"isExpired": false,
"hasPlacement": true,
"signedPdfHash": "a1b2c3d4e5f6...",
"sourceFileNames": ["ISP.pdf", "Goods Form.pdf"],
"placementUrl": null,
"signingUrl": null,
"verifiedBadgeApplied": true,
"initialsFields": [
{ "id": 1, "x": 450, "y": 700, "width": 60, "height": 30, "page": 2, "isRequired": true }
],
"signatures": [
{
"id": 10,
"signerName": "John Smith",
"signerEmail": "john@example.com",
"signatureFont": "Dancing Script",
"ipAddress": "203.0.113.42",
"platform": "Windows",
"timezone": "America/New_York",
"signatureHash": "a1b2c3d4e5f6...",
"createdDate": "2026-05-17T14:30:00Z"
}
],
"signers": []
}
Each signature row names the signer who produced it (signerName / signerEmail). For multi-signer documents the response also carries a signers array — the same per-signer progress entries returned by the signers endpoint below; it is empty for single-signer documents, whose document-level status already tells the whole story.
hasPlacement flag tells you whether placement has been completed.
Check Document Status
A lightweight endpoint to check the current status of a document and its signers.
Document Status Values
| Status | Meaning |
|---|---|
pending | Created but fields not yet placed |
sent | Fields placed, signing link is active |
partially_signed | Some (but not all) signers have signed (multi-signer only) |
signed | All signers have signed |
Expiration is not a stored status. To detect expired documents, check isExpired on the response, or pass ?status=expired to the list endpoint as a convenience filter.
Check Signer Status
The signing-progress view of one document: who has signed (and when), whose turn it is right now, and who is still waiting to be emailed. Single-signer documents return one synthesized entry for the recipient, so you can render every document the same way.
Response (200 OK)
{
"documentId": 43,
"workflowType": "sequential",
"status": "partially_signed",
"isExpired": false,
"signedCount": 1,
"totalSigners": 2,
"signers": [
{
"signerId": 1,
"name": "Alice",
"email": "alice@acme.com",
"role": "Manager",
"signOrder": 1,
"status": "signed",
"signedDate": "2026-05-17T14:30:00Z",
"isNext": false
},
{
"signerId": 2,
"name": "Bob",
"email": "bob@acme.com",
"role": null,
"signOrder": 2,
"status": "sent",
"signedDate": null,
"isNext": true
}
]
}
statusper signer ispending(not yet emailed),sent(their signing link is out), orsigned.isNextistruefor every signer who can sign right now: in asequentialworkflow that is only the next unsigned signer in the chain; in aparallelworkflow it is every unsigned signer. Alwaysfalseonce the document is signed or voided.- For a single-signer document,
signerscontains one entry withsignerId: nullbuilt from the document's recipient.
Resend / Renew a Document
Get a working signing link back for a document that was sent but not yet signed — or whose link has already expired (still unsigned). The v1 API is URL-based (it never emails), so resend hands you a link to redeliver rather than sending mail.
- If the existing link is still valid, it is returned unchanged.
- If the link has expired — or the hourly cleanup job already scrubbed the token — the deadline is extended by the tenant's
TokenExpirationHours(default 72h) and any missing token is regenerated. The same document is reused (documentIdandpublicIdare unchanged); the response fieldrenewed: truesignals this happened, and the old URL stops working.
No request body is required. Returns 400 if the document is already signed, or if a single-signer document's fields were never placed; 404 if not found. For multi-signer documents the response lists the actionable pending signers (the next signer for sequential, all pending signers for parallel) each with a fresh signingUrl.
Void a Document
Retract a document that hasn’t fully signed — the first half of a correct-and-reissue loop.
Optional JSON body: { "reason": "..." } (max 500 chars; recorded in the audit trail and webhook).
- Allowed while
pending,sentorpartially_signed— even after some signers have signed. Refused (400) once every signer has signed. - Every outstanding signing link dies immediately. A signer clicking a stale link sees an explicit “document was voided — check for a newer signing email” page (HTTP 410,
code: "document_voided"). - Emits a
document.voidedwebhook and audit event. The document stays visible withstatus: "voided"— void is not delete; the trail is evidence. - To reissue: fix the content and create/send a new document.
resendandembedded-sessionrefuse voided documents.
Delete a Document
Delete a pending document. Signed documents cannot be deleted through the API — that rule is deliberate and permanent. To get a signed document out of your lists (test runs, stale records), use archive instead.
Archive a Document
Hide a document from default lists without touching the record — the sanctioned way to declutter signed documents, which can never be deleted or voided. Typical use: integration-test documents that completed for real but have no business value.
- Any status can be archived. Nothing else changes: the record, its files, its signing links, its
publicIdverification, and its audit trail all keep working exactly as before. Archive is not void and not delete. - Archived documents disappear from
GET /api/v1/documents(and the dashboard list and stats) unlessincludeArchived=trueis passed. All by-id endpoints (/documents/{id},/download,/separate-files, …) ignore archive entirely. - Fully reversible:
unarchiveputs the document back. Both calls are idempotent and return200; archiving an already-archived document does not reset itsarchivedAt. - Recorded in the audit trail as
document.archived/document.unarchived. No webhook is emitted — archive is a list-level preference, not a document lifecycle change.
// Response (200)
{
"message": "Document archived",
"documentId": 42,
"archivedAt": "2026-08-27T17:30:00Z"
}
Download Signed PDF
Downloads the fully signed PDF with the Certificate of Completion appended, once the document status is signed (404 with "Signed document not available yet" before that). The returned bytes are the verification artifact: their SHA-256 matches signedPdfHash from the detail endpoint. The download is logged in the audit trail.
Alternative routes for other callers:
Download as Separate Files
For a signed document that was created from two or more uploaded files (multipart files on create), returns a ZIP containing each original file as its own signed PDF — cut along the page boundaries recorded when the files were merged — plus the Certificate of Completion pages as a separate Certificate of Completion.pdf. Signature and field content is flattened onto the pages at signing time, so every split file is a complete signed document.
- The detail endpoint's
sourceFileNamesarray tells you up front which files the ZIP will contain. An empty array means the document was sent as a single file (or predates per-file tracking) and this endpoint returns400. - The split files are on-demand convenience copies. The combined signed PDF from
/downloadand itssignedPdfHashremain the sole verification artifacts — split files do not carry their own hash. - Content type is
application/zip; the download is logged in the audit trail asseparate_files.downloaded.
Verify a Signed Document
ctSignature provides a public verification system. Anyone with the document's public ID can verify its authenticity.
Look Up Verification Info
// Response
{
"documentId": "550e8400-e29b-41d4-a716-446655440000",
"status": "signed",
"signedPdfHash": "a1b2c3d4e5f6...",
"signers": [
{ "name": "John Smith", "signedDate": "2026-04-17T14:30:00Z" }
]
}
Verify a File's Hash
Compute the SHA-256 hash of your PDF file and submit it to check whether it matches the original signed document.
POST /api/documents/verify/550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"hash": "a1b2c3d4e5f6..."
}
// Response
{
"match": true,
"documentId": "550e8400-e29b-41d4-a716-446655440000",
"completedAt": "2026-04-17T14:30:00Z"
}
Embedded Signing Sessions
If your subscription includes embedded signing, you can host the signing experience inside your app using an iframe.
Step 1: Enable Embedded Signing
PUT /api/dashboard/embedded-signing
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"enabled": true,
"allowedDomains": ["https://app.yourdomain.com"]
}
Step 2: Create an Embedded Session
// Response
{
"signingUrl": "https://ctsign.io/sign/document/xyz...?embed=true",
"expiresAt": "2026-04-17T11:00:00Z"
}
Step 3: Embed in Your Page
<iframe
src="https://ctsign.io/sign/document/xyz...?embed=true"
width="100%"
height="800"
frameborder="0"
></iframe>
<script>
window.addEventListener('message', function(event) {
// Verify origin matches your ctSignature domain
if (event.origin !== 'https://ctsign.io') return;
if (event.data.type === 'ctSignature:signed') {
console.log('Document signed!', event.data.documentId, event.data.signedPdfUrl);
// Close the modal, refresh your record, etc.
}
if (event.data.type === 'ctSignature:error') {
console.warn('Signing error:', event.data.title, event.data.message);
}
});
</script>
The iframe posts ctSignature:ready when loaded, ctSignature:signed
(with documentId and signedPdfUrl) on completion, and
ctSignature:error on a blocking error.
Quick Sign (pre-authenticated users)
For signers already authenticated inside your application, append &quick=true to the
embedded signing URL. The signature dialog opens immediately: the signer types their name, confirms intent,
and every signature, printed-name and date field completes from that one entry. Only engaged when no field
needs per-field input (a text or initials field falls back to the standard click-each flow). Use plain
embed=true for a signer who should review the document before signing.
Partners can additionally attest that their application obtains ESIGN/UETA consent from its own users
(hostManagedConsent on the Partner API’s embedded-signing endpoint) — signers on
host-delivered documents then skip the consent disclosure entirely, and each signature’s audit trail
records consent as host-asserted. Email-delivered signers always see the disclosure.
Section 4
Templates API
Templates let you upload a PDF or Word document once, position the signature fields, and then reuse it for many recipients without re-uploading or re-placing fields each time. Word files are converted to PDF on upload.
Create a Template
| Field | Type | Required | Description |
|---|---|---|---|
File | file | Yes | PDF or Word file (Word is converted to PDF) |
Name | string | Yes | Template name (e.g., "Employee NDA") |
Description | string | No | Optional description |
You can also create a template from an existing signed document:
Requires a JSON body with the new template’s name (and optional description). The original (unsigned) PDF and all field placements are copied into a new template.
{
"name": "Standard NDA",
"description": "Copied from doc 42"
}
Other Template Endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/v1/templates | List all active templates |
GET | /api/v1/templates/{id} | Get template details with field coordinates |
PUT | /api/v1/templates/{id} | Update name or description |
PUT | /api/v1/templates/{id}/signers | Define the template’s signer roles |
DELETE | /api/v1/templates/{id} | Deactivate template (soft delete) |
Multi-Signer Templates
A template can require signatures from two or more parties. The template stores roles — “Client”, “Contractor”, “Witness” — not people. Each role gets its own signature, initials, date and text fields on the PDF; the actual names and email addresses are supplied at send time, along with whether the routing is sequential or parallel.
Send 2–10 roles, or an empty list to turn the template back into a single-signer one. This call
replaces the whole role list: omit a role’s id to create it, include the id
to keep it, and leave a role out to delete it (its placed fields go with it).
PUT /api/v1/templates/5/signers
Authorization: Bearer ctds_yourApiKey
Content-Type: application/json
{
"signers": [
{ "roleName": "Client", "signOrder": 1 },
{ "roleName": "Contractor", "signOrder": 2 }
],
"defaultWorkflowType": "sequential"
}
After defining roles, place each role’s fields. PUT /api/v1/templates/{id}/placement takes a
per-role payload instead of a flat fields list; signerId is the role’s id.
Every role needs at least one signature field, and sender_text boxes belong to
documentFields because they are typed once by the sender, not by any signer.
PUT /api/v1/templates/5/placement
{
"signers": [
{ "signerId": 11, "fields": [ { "type": "signature", "x": 100, "y": 620, "width": 150, "height": 32, "page": 1 } ] },
{ "signerId": 12, "fields": [ { "type": "signature", "x": 340, "y": 620, "width": 150, "height": 32, "page": 1 } ] }
],
"documentFields": [
{ "type": "sender_text", "x": 100, "y": 700, "width": 220, "height": 30, "page": 1, "value": "Contract #4471" }
]
}
GET /api/v1/templates/{id} returns the roles under signers[], each with its own
fields[] and a hasSignatureField flag. The template’s top-level
fields[] holds only the sender-text boxes. hasPlacement is false
until every role has a signature field.
Anchored Fields (Mail-Merge Forms)
Fixed coordinates assume the form never moves. A mail-merged form does move: the amount of content above a
signature line changes per document, so the line drifts down the page — sometimes onto the next page.
An anchored field is positioned by finding text in each sent document instead:
put an invisible marker (white ~8pt text such as [[ctsig:sc:sign]]) in your source document
where the field belongs, and the field is placed wherever that marker lands in every render.
Set anchors in the dashboard template editor (Anchor panel) or in the placement payload — each field object accepts:
| Property | Description |
|---|---|
anchorText | Text to find (max 200 chars). Matched case-insensitively, whitespace ignored. When set, the field’s x/y/page are ignored; width/height still apply. |
anchorOccurrence | Which match to use when the text appears more than once (1-based, reading order). Default 1. |
anchorColumnWord | Optional word within the match that supplies the horizontal position. |
anchorOffsetX / anchorOffsetY | Points to shift from the found text. Y grows downward; negative lifts the field. |
Anchors resolve once, at send time, against that document’s text layer. A marker that can’t be found fails the send with HTTP 400 naming the field and marker — nothing is created or billed. Scanned (image-only) PDFs cannot be anchored: they have no text to search.
Send Your Own PDF Through a Template
Send from a Template copies the template’s stored PDF — right when the document never changes. When your application generates a different PDF every time (a mail merge), use this endpoint instead: the template contributes the roles and field definitions (usually anchored), and the document travels with the request. Multipart form-data; requires a multi-role template.
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | The PDF for this send (or files to merge several; Word converted) |
Signers | JSON string | Yes | One entry per role: [{"templateSignerId": 11, "signerName": "...", "signerEmail": "..."}] |
WorkflowType | string | Yes | sequential or parallel |
SendInvite | bool | No | false to deliver signing URLs yourself (in-app flows) |
ExpirationHours, RemindersEnabled, NotificationEmail and
ReplyToEmail work as on other create endpoints. The response matches
Send from a Template: documentId plus a signingUrl per signer.
Send from a Template
Creates a new document from the template with pre-placed fields and sends the signing link directly. No placement step needed. notificationEmail and replyToEmail (see Where Signer Replies Go) are accepted here and on batch send; on a batch, one replyToEmail applies to every document in the call.
POST /api/v1/templates/5/send
Authorization: Bearer ctds_yourApiKey
Content-Type: application/json
{
"recipientName": "Sarah Johnson",
"recipientEmail": "sarah@example.com",
"expirationHours": 48,
"replyToEmail": "casemanager@youragency.com"
}
// Response
{
"documentId": 44,
"signingUrl": "https://ctsign.io/sign/document/xyz...",
"templateId": 5,
"skippedPlacement": true
}
Sending a multi-signer template
When the template has roles, send a signers array instead of recipientName/recipientEmail,
with one entry per role, plus the workflowType:
"sequential"— one at a time. Only the first signer is emailed now; each later signer is emailed automatically as the one before them finishes.signOrdersets the running order and overrides the role’s template default."parallel"— everyone is emailed immediately and can sign in any order.signOrderis ignored.
POST /api/v1/templates/5/send
{
"workflowType": "sequential",
"signers": [
{ "templateSignerId": 11, "signerName": "Sarah Johnson", "signerEmail": "sarah@example.com", "signOrder": 1 },
{ "templateSignerId": 12, "signerName": "Dan Reyes", "signerEmail": "dan@contractor.com", "signOrder": 2 }
]
}
// Response
{
"documentId": 44,
"workflowType": "sequential",
"templateId": 5,
"templateName": "Consulting Agreement",
"signers": [
{ "signerId": 88, "roleName": "Client", "signerName": "Sarah Johnson",
"signerEmail": "sarah@example.com", "signOrder": 1,
"signingUrl": "https://ctsign.io/sign/document/abc...", "notified": true },
{ "signerId": 89, "roleName": "Contractor", "signerName": "Dan Reyes",
"signerEmail": "dan@contractor.com", "signOrder": 2,
"signingUrl": "https://ctsign.io/sign/document/def...", "notified": false }
]
}
400 naming the roles that are missing.
notified: false means that signer has not been emailed yet because it is not their turn —
their signingUrl is still valid if you would rather deliver it yourself.
Batch Send
Send a template to up to 100 recipients in a single call. One document is created per recipient.
POST /api/v1/templates/5/batch-send
Authorization: Bearer ctds_yourApiKey
Content-Type: application/json
{
"recipients": [
{ "name": "Alice Brown", "email": "alice@acme.com" },
{ "name": "Bob Green", "email": "bob@acme.com" },
{ "name": "Carol White", "email": "carol@acme.com" }
]
}
// Response
{
"templateId": 5,
"totalSent": 3,
"documents": [
{ "documentId": 45, "recipientEmail": "alice@acme.com", "signingUrl": "..." },
{ "documentId": 46, "recipientEmail": "bob@acme.com", "signingUrl": "..." },
{ "documentId": 47, "recipientEmail": "carol@acme.com", "signingUrl": "..." }
]
}
Batch sending a multi-signer template
Send signerSets instead of recipients — one entry per document, each filling
every role. workflowType applies to all documents in the batch. Up to 100 documents per call.
Every set is validated before anything is created, so a bad row fails the whole call rather than leaving a
half-sent batch.
POST /api/v1/templates/5/batch-send
{
"workflowType": "parallel",
"signerSets": [
{ "signers": [
{ "templateSignerId": 11, "signerName": "Alice Brown", "signerEmail": "alice@acme.com" },
{ "templateSignerId": 12, "signerName": "Dan Reyes", "signerEmail": "dan@contractor.com" }
]},
{ "signers": [
{ "templateSignerId": 11, "signerName": "Bob Green", "signerEmail": "bob@acme.com" },
{ "templateSignerId": 12, "signerName": "Dan Reyes", "signerEmail": "dan@contractor.com" }
]}
]
}
documents[] carries a signers[] array with one
signing URL per person, in the same shape as the single multi-signer send above.
Section 5
Recipients API (Address Book)
Save frequently-used signers so you don't have to re-enter their info each time. Recipients are automatically added when you create multi-signer documents from the dashboard.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/recipients | List recipients (supports ?search= and ?includeInactive=true) |
GET | /api/v1/recipients/{id} | Get recipient details |
POST | /api/v1/recipients | Create recipient |
PUT | /api/v1/recipients/{id} | Update recipient |
DELETE | /api/v1/recipients/{id} | Deactivate recipient |
POST | /api/v1/recipients/{id}/reactivate | Reactivate a deactivated recipient |
Create Recipient
POST /api/v1/recipients
Authorization: Bearer ctds_yourApiKey
Content-Type: application/json
{
"name": "John Smith",
"email": "john@example.com",
"company": "Example Inc",
"role": "VP of Sales",
"phone": "+1-555-123-4567",
"notes": "Prefers signing on mobile"
}
// Response
{
"id": 1,
"name": "John Smith",
"email": "john@example.com",
"company": "Example Inc",
"role": "VP of Sales",
"phone": "+1-555-123-4567",
"notes": "Prefers signing on mobile",
"isActive": true,
"createdDate": "2026-04-17T10:00:00Z"
}
Section 6
Webhooks
Webhooks let your application receive real-time notifications when events happen in ctSignature — like when a document is signed, viewed, or completed.
Setting Up Webhooks
POST /api/v1/webhooks
Authorization: Bearer ctds_yourApiKey
Content-Type: application/json
{
"url": "https://yourapp.com/webhooks/ctsignature",
"events": ["document.completed", "signer.signed"],
"description": "Production webhook"
}
// Response
{
"id": 1,
"url": "https://yourapp.com/webhooks/ctsignature",
"events": ["document.completed", "signer.signed"],
"secret": "a1b2c3d4e5f6...64-hex-chars...", // shown only once!
"isActive": true,
"createdDate": "2026-04-17T10:00:00Z"
}
secret is only returned when the webhook is created. Store it securely — you need it to verify incoming webhook payloads. If you lose it, delete the webhook and create a new one.
In production, the URL must use HTTPS.
Event Types
Subscribe to any combination of these events. If you don't specify events, you receive all webhook-emitting events.
| Event | Fires When |
|---|---|
document.created | A new document is uploaded via the API |
document.sent | Signature fields are placed and the signing link becomes active |
signer.signed | A signer submits their signature |
document.completed | All signers have signed the document |
Payload Format
Webhooks are sent as HTTP POST requests with a JSON body:
{
"id": "whd_123",
"timestamp": "2026-05-17T14:30:00Z",
"event": "signer.signed",
"data": {
"documentId": 42,
"publicId": "550e8400-e29b-41d4-a716-446655440000",
"filename": "contract.pdf",
"signerName": "John Smith",
"signerEmail": "john@example.com",
"signedAt": "2026-05-17T14:30:00Z"
}
}
Headers Sent
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | ctSignature-Webhook/1.0 |
X-Webhook-Id | Numeric ID of the webhook endpoint receiving the event |
X-Timestamp | Unix epoch seconds at the moment the signature was computed (used in the signing input — see below) |
X-Signature | Hex-encoded HMAC-SHA256. No prefix — just the hex digest. |
Verifying Webhook Signatures
Always verify the X-Signature header before processing a webhook.
How it works
- Read the raw request body as a UTF-8 string (do not re-serialize the JSON — whitespace matters).
- Read the
X-Timestampheader. - Build the signing string:
{timestamp}.{rawBody}(timestamp, a literal dot, then the body). - Compute HMAC-SHA256 using your webhook secret. The secret is a 64-character hex string; decode it to bytes with hex-decoding before using it as the HMAC key.
- Hex-encode the result (lowercase) and compare to
X-Signatureusing a constant-time comparison. - Reject the request if the timestamp is more than a few minutes old to prevent replay.
Node.js Example
const crypto = require('crypto');
// Use express.raw({ type: 'application/json' }) so req.body is a Buffer.
function verifyWebhook(req, secret) {
const rawBody = req.body.toString('utf8');
const timestamp = req.headers['x-timestamp'];
const signature = req.headers['x-signature'];
if (!timestamp || !signature) return false;
const expected = crypto
.createHmac('sha256', Buffer.from(secret, 'hex'))
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python Example
import hmac, hashlib
def verify_webhook(raw_body: bytes, secret: str, timestamp: str, signature: str) -> bool:
signing_input = f"{timestamp}.".encode('utf-8') + raw_body
expected = hmac.new(
bytes.fromhex(secret),
signing_input,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature.lower())
C# Example
using System.Security.Cryptography;
using System.Text;
bool VerifyWebhook(string rawBody, string secret, string timestamp, string signature)
{
var key = Convert.FromHexString(secret);
using var hmac = new HMACSHA256(key);
var input = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}");
var expected = Convert.ToHexString(hmac.ComputeHash(input)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature.ToLowerInvariant())
);
}
Retries & Deliveries
Each event is queued and picked up by a background dispatcher that polls every ~15 seconds. If your endpoint returns a non-2xx status code (or times out after 10 seconds), ctSignature retries with the following backoff:
| Attempt | Delay after previous failure |
|---|---|
| 1 | — (initial delivery, within ~15s of the event) |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
After 5 failed attempts the delivery is marked as exhausted. You can view delivery history from the dashboard:
limit is clamped to 1–100 (default 25).
To test your webhook endpoint without creating a real document:
Section 7
Signing Workflow
Single-Signer Flow
This is the complete lifecycle of a single-signer document:
pending. You receive a placementUrl and signingUrl.
sent. An email is sent to the signer automatically.
signed.
document.completed webhook. The signed PDF is available for download.
Multi-Signer Flow
Sequential Workflow
- Create multi-signer document with
workflowType: "sequential" - Place fields for all signers on the placement page
- Signer 1 receives an email. They sign.
- Only after Signer 1 completes does Signer 2 receive their email
- This continues through all signers in order
- When the last signer signs, the document status becomes
signed
Parallel Workflow
- Create multi-signer document with
workflowType: "parallel" - Place fields for all signers
- All signers receive emails at the same time
- Signers can sign in any order
- When the last remaining signer completes, the document is fully signed
Consent & OTP Verification
These features are configured per-tenant in the dashboard settings.
Consent Disclosure
When enabled, signers must accept a legal disclosure before they can sign. This is required for ESIGN Act compliance.
- Modal mode: The disclosure appears as a popup on the signing page
- Page mode: The signer is taken to a full-page disclosure before seeing the document
You can publish custom disclosure text. Each change creates a new version so you have a record of which version each signer accepted.
OTP (One-Time Password)
When enabled, signers must verify their identity by entering a 6-digit code sent to their email before they can sign. This adds an extra layer of identity verification.
- OTP codes expire after 10 minutes
- Maximum 5 verification attempts per code
- Each new code invalidates any previous unverified codes
Section 8
Billing & Subscriptions
Free Trial
New accounts start with a free trial that includes 3 documents. All features are available during the trial.
Subscription Tiers
Returns available subscription plans with pricing and included features.
// Response
[
{
"id": 1,
"name": "Starter",
"monthlyPriceCents": 2999,
"includedDocuments": 50,
"overagePriceCents": 150,
"featureFlags": { "webhooks": true, "templates": true }
},
{
"id": 2,
"name": "Professional",
"monthlyPriceCents": 7999,
"includedDocuments": 200,
"overagePriceCents": 100,
"featureFlags": { "webhooks": true, "templates": true, "embedding": true }
}
]
Check Usage
// Response
{
"documentsUsedThisCycle": 37,
"includedDocuments": 50,
"trialRemaining": 0,
"billingPeriodStart": "2026-04-01T00:00:00Z",
"billingPeriodEnd": "2026-04-30T23:59:59Z"
}
Upgrade / Manage Subscription
To start or change a subscription:
POST /api/billing/checkout
Authorization: Bearer <jwt-token>
Content-Type: application/json
{ "tierId": 2 }
// Response
{ "checkoutUrl": "https://checkout.stripe.com/pay/cs_..." }
Redirect the user to checkoutUrl to complete payment on Stripe.
To manage payment methods, view invoices, or cancel:
Returns a Stripe Customer Portal URL.
Section 9
Partner API (OEM Provisioning)
The Partner API lives at /api/platform/v1/*. A Partner is registered by a platform operator (see Admin → Partners on the platform admin console) and gets a distinct key type — ctps_live_… — that authorizes them to create, list, update, and disable tenants, plus mint API keys, configure webhooks, and enable embedded signing on behalf of those tenants.
Ownership is enforced by a nullable Tenant.PartnerId column:
- Tenants provisioned via the Partner API are tagged with the calling partner’s id.
- Tenants that sign up directly at
ctsign.iohavePartnerId = nulland are invisible to every partner. - A partner can only see / act on tenants where
PartnerIdmatches them. 404s on cross-partner IDs do not distinguish “does not exist” from “owned by someone else.”
Partner Key Authentication
Partner keys follow the pattern ctps_<random-characters> (note: ctps_, not ctds_). They are sent on the Authorization header with the Bearer scheme:
Authorization: Bearer ctps_live_aB3cDeFgH1iJkLmN2oPqRsT3uVwXy
ctds_live_…) will NOT authenticate at /api/platform/v1/*, and a partner key (ctps_live_…) will NOT authenticate at /api/v1/*. The two key types are deliberately non-overlapping — mixing them up produces a 401.
Where keys come from
A platform operator creates a Partner row and mints keys for it from the platform admin console:
- Sign in to the platform admin at
https://ctsign.io/dashboard/admin.html. - Open the Partners tab.
- Click New Partner, enter name + contact email, save.
- Click Keys on the partner’s row, enter a label (e.g. Production), click Create Key.
- Copy the
ctps_live_…value — it is shown once.
Hand the key to the partner via a secure channel; they store it as an environment variable (e.g. CTSIGN_PARTNER_KEY) and use it server-side.
One-Call Tenant Onboarding
The headline endpoint is POST /api/platform/v1/tenants. A single call can provision the local tenant, provision the matching org in ctOneAuth, mint a tenant API key, register a webhook, and enable iframe embedding — replacing the entire ctsign.io sign-up + password-setup + key-copy flow.
POST /api/platform/v1/tenants
Authorization: Bearer ctps_live_aB3cDeFgH1iJkLmN2oPqRsT3uVwXy
Content-Type: application/json
{
"companyName": "Smart of South Jersey, LLC",
"adminName": "Gary Coslop",
"adminEmail": "gary@smartsouthjersey.com",
"mintApiKey": true,
"replyToEmail": "documents@smartsouthjersey.com",
"webhook": {
"url": "https://pnbv5.cozzitech.com/webhooks/ctsign",
"events": ["document.completed", "signer.signed"]
},
"embeddedSigning": {
"enabled": true,
"allowedDomains": ["https://pnbv5.cozzitech.com"]
}
}
// Response — everything shown once is shown ONCE.
{
"tenantId": 123,
"companyName": "Smart of South Jersey, LLC",
"admin": {
"userId": 456,
"name": "Gary Coslop",
"email": "gary@smartsouthjersey.com"
},
"setupUrl": "https://auth.cozzitech.com/identity-ui/setup/<one-time-token>",
"linkedExistingUser": false,
"apiKey": "ctds_live_aBcDeFg...", // shown ONCE
"webhook": {
"id": 7,
"url": "https://pnbv5.cozzitech.com/webhooks/ctsign",
"secret": "a1b2c3...64-hex-chars...", // shown ONCE
"events": ["document.completed", "signer.signed"]
},
"embeddedSigningEnabled": true,
"createdDate": "2026-06-09T19:01:23Z"
}
What to do with each field of the response:
tenantId— persist on your side; use it to scope every subsequent partner-API call (/tenants/{tenantId}/...).apiKey— this is actds_live_tenant key. Store it securely. You can now call the tenant API (/api/v1/documents, etc.) on the tenant’s behalf without ever asking the human admin to sign in.setupUrl— the IdP password-setup URL. Forward it to the admin verbatim (email, deep link from your UI, whatever you like). Do not append query parameters — the URL is signed.webhook.secret— the HMAC signing secret. Store it; use it to verify inbound webhook signatures (see Section 6.4).linkedExistingUser— whentrue, the admin email already existed in ctOneAuth and was linked to the new tenant instead of being issued a fresh password-setup link. In this casesetupUrlisnull— the admin can already sign in.
mintApiKey, webhook, embeddedSigning and replyToEmail are all optional. If omitted, you can call the corresponding endpoints later: POST /tenants/{id}/api-keys, POST /tenants/{id}/webhooks, PUT /tenants/{id}/embedded-signing, PUT /tenants/{id}. Bundling them inline is just a convenience.
Endpoint Reference
Every endpoint is scoped to Tenant.PartnerId == <calling partner>. All paths are prefixed with /api/platform/v1.
Tenants
| Method | Path | Purpose |
|---|---|---|
POST | /tenants | Create tenant (with optional inline mintApiKey, webhook, embeddedSigning). |
GET | /tenants?page=&pageSize=&search= | List tenants this partner owns. |
GET | /tenants/{tenantId} | Tenant detail. |
PUT | /tenants/{tenantId} | Update { companyName?, isActive?, replyToEmail? }. Send replyToEmail: "" to clear it; omit to leave it alone. |
DELETE | /tenants/{tenantId} | Soft-disable. Sets IsActive=false; signed PDFs remain accessible; sign-in and new document creation are blocked. |
Users
| Method | Path | Purpose |
|---|---|---|
GET | /tenants/{tenantId}/users | List team members. |
POST | /tenants/{tenantId}/users | Invite { name, email, role, expiresInHours? }. Role is "Admin" or "User". |
PUT | /tenants/{tenantId}/users/{userId} | Update { name?, email?, role? }. |
POST | /tenants/{tenantId}/users/{userId}/deactivate | Disable a user. |
POST | /tenants/{tenantId}/users/{userId}/activate | Re-enable. |
POST | /tenants/{tenantId}/users/{userId}/resend-invite | Reissue the IdP setup link. |
API Keys (on the tenant’s behalf)
| Method | Path | Purpose |
|---|---|---|
GET | /tenants/{tenantId}/api-keys | List keys. |
POST | /tenants/{tenantId}/api-keys | Mint a new ctds_live_ key. fullKey shown ONCE. |
DELETE | /tenants/{tenantId}/api-keys/{keyId} | Revoke. |
Webhooks
| Method | Path | Purpose |
|---|---|---|
GET | /tenants/{tenantId}/webhooks | List endpoints. |
POST | /tenants/{tenantId}/webhooks | Create { url, events[], description? }. secret shown ONCE. |
DELETE | /tenants/{tenantId}/webhooks/{webhookId} | Delete. |
Embedded Signing
| Method | Path | Purpose |
|---|---|---|
GET | /tenants/{tenantId}/embedded-signing | Current allowlist. |
PUT | /tenants/{tenantId}/embedded-signing | Update { enabled, allowedDomains[] }. Domains are full origins including https://. |
Gotchas
- One-shot secrets.
apiKey(from a tenant key mint) andwebhook.secret(from webhook creation) are returned once at creation. There is no recovery endpoint — revoke and re-mint if lost. - Soft-disable, not hard-delete.
DELETE /tenants/{id}setsIsActive=false. We deliberately do not hard-delete: that would orphan signed PDFs and the audit trail. There is no “undelete”; justPUTwith{ "isActive": true }to re-enable. - Partner-scoped duplicate guard.
POST /tenantsreturns409if the same admin email and the same company name already exists under this partner. Different company name (or different partner) is allowed. - 404 vs 403. Any tenant-path call where
tenantIdbelongs to a different partner returns404(not403). This is intentional — we don’t want partners to discover the existence of other partners’ tenants by ID enumeration. - Welcome email is suppressed. Tenants created via the Partner API do NOT trigger the standard
ctsign.iowelcome email — the partner is expected to deliver any onboarding messaging themselves. - Set
replyToEmailat provision time. Documents a tenant later creates with its API key have no signed-in user, so signer replies fall through to ctSignature unless the tenant-wide default is set. See Where Signer Replies Go.
Section 10
Dashboard Guide
The web dashboard gives tenant admins and users a visual interface for managing documents, templates, users, settings, and billing.
Documents
The Documents section lets you:
- Create documents — upload a PDF or select a template, enter recipient info
- Create multi-signer documents — add multiple signers with roles and ordering
- Track status — see pending, sent, signed, and expired documents
- Resend emails — re-send signing request emails to recipients
- Download signed PDFs — download the completed document with certificate
- View audit trail — see who signed, when, from where, and with what device
Dashboard Document Creation Endpoint
Same as the API v1 endpoint, but also supports creating documents from templates (when a template has pre-placed fields, it skips the placement step and sends immediately).
Stats Overview
Returns real-time document counts by status and trial information.
Templates
Templates are managed through the same API endpoints described in Section 4. From the dashboard, you can:
- Upload new templates
- Create templates from previously signed documents
- Send individual or batch documents from templates
- Edit template names and descriptions
- Deactivate templates you no longer need
User Management
Tenant admins can invite team members and control their access.
User Roles
| Role | Permissions |
|---|---|
Admin | Full access: documents, templates, settings, billing, users, webhooks, API keys |
User | Create and manage documents and templates. Cannot change settings, billing, or users. |
Reviewer | View documents only. Cannot create or modify. |
User Management Endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/dashboard/users | List all users in the tenant |
POST | /api/dashboard/users | Invite a new user (by email) |
PUT | /api/dashboard/users/{id}/role | Change a user's role |
POST | /api/dashboard/users/{id}/deactivate | Disable a user's access |
POST | /api/dashboard/users/{id}/activate | Re-enable a disabled user |
POST | /api/dashboard/users/{id}/reset-password | Force a password reset |
Settings & Branding
Account Settings
Update company name, contact email, or password.
Consent Settings
Read the current consent configuration:
Updates use separate sub-paths:
| Method | Path | Description |
|---|---|---|
PUT | /api/dashboard/consent/flow | Set flow type (modal or page) |
POST | /api/dashboard/consent/disclosure | Publish a new disclosure version |
PUT | /api/dashboard/consent/otp | Enable or disable OTP verification |
Branding
| Method | Path | Description |
|---|---|---|
GET | /api/dashboard/branding | Get current branding settings |
PUT | /api/dashboard/branding | Update colors, brand name and replyToEmail |
POST | /api/dashboard/branding/logo | Upload logo (PNG, JPG, SVG; max 2 MB) |
DELETE | /api/dashboard/branding/logo | Remove logo |
replyToEmail is the tenant-wide default Reply-To for signer emails (Branding page → Signer Replies) — see Where Signer Replies Go. Send "" to clear it; omit the field to leave it unchanged.
Document Retention
Set how long signed documents are kept before automatic deletion. Range: 30 to 36,500 days (default: 2,555 days / ~7 years).
Embedded Signing Settings
Enable or disable iframe-based embedded signing and set allowed domains.
Analytics
Returns daily document creation and signing counts for charting. Supports 7, 30, 60, and 90 day windows.
Returns aggregate statistics: total documents, completion rate, average time to sign, and template usage count.
Section 11
Configuration Reference
ctSignature is configured through appsettings.json (and environment-specific overrides). Below are all the settings you need to know.
Database
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=ctDocSign;User=ctdocsign;Password=YOUR_PASSWORD;Port=3306;"
}
Requires MySQL 8.0 or later. Entity Framework Core handles migrations automatically.
JWT Settings
| Setting | Default | Description |
|---|---|---|
Jwt:Secret | — | Signing key for JWT tokens. Must be at least 32 characters. Change this in production! |
Jwt:Issuer | ctDocSign | Issuer claim in JWT tokens |
Jwt:Audience | ctDocSign-dashboard | Audience claim in JWT tokens |
Jwt:ExpirationHours | 24 | How long JWT tokens are valid |
Stripe Settings
| Setting | Description |
|---|---|
Stripe:SecretKey | Stripe API secret key (sk_live_... or sk_test_...) |
Stripe:PublishableKey | Stripe publishable key (for frontend) |
Stripe:WebhookSecret | Stripe webhook endpoint signing secret (whsec_...) |
Platform Admin
| Setting | Description |
|---|---|
PlatformAdmin:Email | Email for platform admin login |
PlatformAdmin:Password | Password for platform admin login |
PlatformAdmin:Secret | Secret for admin endpoint validation |
Document Storage & Security
| Setting | Default | Description |
|---|---|---|
DocumentSigning:Storage:BasePath | ./Documents/Signing | Root directory for stored PDFs |
DocumentSigning:Storage:MaxFileSize | 10485760 | Max upload size in bytes (10 MB) |
DocumentSigning:Security:TokenExpirationHours | 72 | Signing link lifetime |
DocumentSigning:Security:AllowedOrigins | [] | CORS allowed origins |
DocumentSigning:ProductionBaseUrl | — | Base URL for signing links in emails |
Testing & Development
| Setting | Default | Description |
|---|---|---|
DocumentSigning:Testing:EnableTestMode | true | Enables the /api/test/ endpoints |
DocumentSigning:Testing:SkipEmailSending | true | Skips actual email delivery in dev |
DocumentSigning:Testing:LocalhostBaseUrl | http://localhost:8080 | Base URL used in dev mode |
Email Providers
Email delivery is configured at runtime by a platform admin through the Platform Admin UI — not via environment variables. The platform admin sets one of the following system settings:
| Setting Key | Provider |
|---|---|
PostmarkApiKey | Postmark (recommended for delivery tracking, bounces, opens) |
ResendApiKey | Resend (alternative provider) |
Self-hosted deployments configure these through /admin-panel/ after the first platform-admin login.
Rate Limits
| Scope | Limit |
|---|---|
| General API | 60 requests/minute |
| Signature submission | 10 requests/minute |
| Test endpoint | 5 requests/minute |
| Tenant API (per key) | 100 requests/minute |
Section 12
Error Handling
All errors return a JSON object with an error field containing a human-readable message.
// Example error response
{
"error": "Document not found"
}
HTTP Status Codes
| Code | Meaning | Common Causes |
|---|---|---|
200 | OK | Request succeeded |
201 | Created | Resource created successfully |
400 | Bad Request | Missing required field, invalid file format, validation error |
401 | Unauthorized | Missing or invalid API key / JWT token |
402 | Payment Required | Billing quota exceeded (trial or subscription limit reached) |
403 | Forbidden | Valid auth but not allowed (wrong tenant, non-admin user) |
404 | Not Found | Document, template, or recipient doesn't exist |
409 | Conflict | Email already registered, duplicate recipient |
429 | Too Many Requests | Rate limit exceeded — slow down and retry |
500 | Internal Server Error | Server-side error (these are logged and monitored) |
Common Error Scenarios
Authentication Errors
// Missing API key 401: { "error": "Authorization header is required" } // Invalid API key 401: { "error": "Invalid API key" } // Expired JWT 401: { "error": "Token has expired" }
Document Errors
// File too large 400: { "error": "File size exceeds the maximum allowed (10 MB)" } // Unsupported file type 400: { "error": "Only PDF, DOC, and DOCX files are accepted" } // Word file could not be converted 400: { "error": "contract.docx: The Word document could not be converted to PDF. Ensure it is a valid, unencrypted document." } // Trying to delete a signed document 400: { "error": "Signed documents cannot be deleted" } // Trial expired 402: { "error": "Trial document limit reached. Please upgrade." }
Signing Errors
// Token expired 400: { "error": "This signing link has expired" } // Consent not given 400: { "error": "Consent must be given before signing" } // OTP not verified 400: { "error": "OTP verification is required before signing" } // Sequential order violation 400: { "error": "Previous signer has not yet signed" } // Too many OTP attempts 400: { "error": "TooManyAttempts" }
Section 13
Security & Compliance
Data Security
- Passwords — hashed with BCrypt (tenant passwords, user passwords, API keys)
- Tokens — compared using timing-safe operations to prevent timing attacks
- File storage — path traversal protection on all file access
- Tenant isolation — every query is scoped to the authenticated tenant; no cross-tenant data access is possible
- Rate limiting — prevents brute-force and abuse
ESIGN Act & UETA Compliance
ctSignature includes the following features to support ESIGN Act and UETA compliance:
| Requirement | How ctSignature Meets It |
|---|---|
| Consent to use electronic signatures | Configurable consent disclosure (modal or full-page) with versioned history. Each signer's consent is timestamped and IP-logged. |
| Intent to sign | Explicit "I intend to sign" confirmation checkbox. Timestamp recorded as IntentToSignAt. |
| Signer identity | Email-based identification. Optional OTP verification for additional identity assurance. Device fingerprinting (canvas, user agent, IP, geolocation). |
| Record retention | Configurable retention period (default 7 years). Signed PDFs include SHA-256 hash for integrity verification. Certificate of Completion documents the full signing process. |
| Delivery evidence | Postmark integration tracks email delivery, bounces, opens, and spam complaints. All events logged in the audit trail. |
| Audit trail | Every action is logged: document creation, field placement, signer views, consent, OTP, signature submission, email events. All entries include actor, timestamp, IP, and metadata. |
Certificate of Completion
Every signed document has a Certificate of Completion automatically appended as the last page. It includes:
- Document public ID (for verification lookup)
- Original filename
- List of all signers with their signed dates
- SHA-256 hash of the signed document
- Audit trail summary
Document Verification
Anyone can verify a signed document's authenticity using the public verification endpoint:
// Look up by public ID (printed on the certificate) GET /api/documents/verify/{publicId} // Or verify a specific file's hash POST /api/documents/verify/{publicId} { "hash": "sha256-hex-string-of-your-file" }
This does not require any authentication.
Security Best Practices for Integrators
- Store API keys in environment variables or a secrets manager — never in source code
- Always verify webhook signatures before processing payloads
- Use HTTPS for all webhook endpoint URLs
- Rotate API keys periodically and revoke unused ones
- Set the shortest reasonable expiration time for signing links
- Enable OTP verification for high-value documents
- Publish a custom consent disclosure that matches your legal requirements
Appendix
Quick Reference — All API Endpoints
Authentication
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/auth/register | None | Create tenant account; returns ctOneAuth setupUrl |
GET | /api/auth/oidc/login | None | Start OIDC sign-in (ctOneAuth) |
GET | /api/auth/me | JWT | Get current user |
Documents (API v1)
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/v1/documents | API Key | Create single-signer document |
POST | /api/v1/documents/multi-signer | API Key | Create multi-signer document |
GET | /api/v1/documents | API Key | List documents (paginated) |
GET | /api/v1/documents/{id} | API Key | Get document details |
GET | /api/v1/documents/{id}/status | API Key | Check status |
GET | /api/v1/documents/{id}/signers | API Key | Per-signer progress: who signed, who's next, who's waiting |
GET | /api/v1/documents/{id}/download | API Key | Download signed PDF (once signed) |
GET | /api/v1/documents/{id}/separate-files | API Key | Signed multi-file document as per-file ZIP |
POST | /api/v1/documents/{id}/resend | API Key | Resend / renew link (unsigned only) |
PUT | /api/v1/documents/{id}/placement | API Key | Re-place fields: move one to another signer, nudge, or add (never emails; statuses unchanged) |
POST | /api/v1/documents/{id}/void | API Key | Void before completion (kills links, keeps audit trail) |
DELETE | /api/v1/documents/{id} | API Key | Delete pending document |
POST | /api/v1/documents/{id}/archive | API Key | Hide from default lists (works for signed docs; reversible) |
POST | /api/v1/documents/{id}/unarchive | API Key | Restore an archived document to the lists |
POST | /api/v1/documents/{id}/embedded-session | API Key | Create embedded signing URL |
Templates (API v1)
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/v1/templates | API Key | Create template from PDF |
POST | /api/v1/templates/from-document/{id} | API Key | Create from signed doc |
GET | /api/v1/templates | API Key | List templates |
GET | /api/v1/templates/{id} | API Key | Get template details |
PUT | /api/v1/templates/{id} | API Key | Update metadata |
DELETE | /api/v1/templates/{id} | API Key | Deactivate template |
POST | /api/v1/templates/{id}/send | API Key | Send to one recipient |
POST | /api/v1/templates/{id}/send-document | API Key | Send caller-supplied PDF using template roles/anchors |
POST | /api/v1/templates/{id}/batch-send | API Key | Send to up to 100 recipients |
Recipients (API v1)
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/v1/recipients | API Key | Create recipient |
GET | /api/v1/recipients | API Key | List recipients |
GET | /api/v1/recipients/{id} | API Key | Get recipient |
PUT | /api/v1/recipients/{id} | API Key | Update recipient |
DELETE | /api/v1/recipients/{id} | API Key | Deactivate recipient |
POST | /api/v1/recipients/{id}/reactivate | API Key | Reactivate |
Webhooks
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/v1/webhooks | API Key | Create webhook endpoint |
GET | /api/v1/webhooks | API Key | List webhooks |
DELETE | /api/v1/webhooks/{id} | API Key | Delete webhook |
GET | /api/dashboard/webhooks/{id}/deliveries | JWT | Delivery history (dashboard only) |
POST | /api/dashboard/webhooks/{id}/test | JWT | Send test event (dashboard only) |
Billing
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/billing/tiers | None | List subscription plans |
GET | /api/billing/usage | JWT | Current usage stats |
POST | /api/billing/checkout | JWT (Admin) | Create Stripe checkout |
POST | /api/billing/portal | JWT (Admin) | Open Stripe portal |
Signing Flow (Token-Based)
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/documents/placement/{token} | Token | Load placement page data |
POST | /api/documents/place/{token} | Token | Submit field positions |
POST | /api/documents/place-multi/{token} | Token | Submit multi-signer positions |
GET | /api/documents/sign/{token} | Token | Load signing page data |
POST | /api/documents/sign/{token} | Token | Submit signature |
GET | /api/documents/consent/{token} | Token | Get consent disclosure |
POST | /api/documents/consent/{token} | Token | Record consent |
GET | /api/documents/otp/status/{token} | Token | Check OTP status |
POST | /api/documents/otp/send/{token} | Token | Send OTP code |
POST | /api/documents/otp/verify/{token} | Token | Verify OTP code |
GET | /api/documents/signed/{id}?token= | Token | Download signed PDF |
GET | /api/documents/verify/{publicId} | None | Public verification lookup |
POST | /api/documents/verify/{publicId} | None | Verify file hash |
Dashboard
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/dashboard/stats | JWT | Document counts |
POST | /api/dashboard/documents | JWT | Create document |
POST | /api/dashboard/documents/multi-signer | JWT | Create multi-signer |
GET | /api/dashboard/documents | JWT | List documents |
GET | /api/dashboard/documents/{id} | JWT | Document details |
GET | /api/dashboard/documents/{id}/download | JWT | Download signed PDF |
POST | /api/dashboard/documents/{id}/resend | JWT | Resend signing email |
GET/POST | /api/dashboard/api-keys | JWT (Admin) | List/create API keys |
DELETE | /api/dashboard/api-keys/{id} | JWT (Admin) | Revoke API key |
GET/PUT | /api/dashboard/account | JWT (Admin) | Account settings |
GET | /api/dashboard/consent | JWT (Admin) | Read consent settings (updates via /consent/flow, /consent/disclosure, /consent/otp) |
GET/PUT | /api/dashboard/branding | JWT (Admin) | Branding |
GET/PUT | /api/dashboard/retention | JWT (Admin) | Retention policy |
GET/PUT | /api/dashboard/embedded-signing | JWT (Admin) | Embedded signing |
GET | /api/dashboard/users | JWT (Admin) | List users |
POST | /api/dashboard/users | JWT (Admin) | Invite user |
GET | /api/dashboard/analytics/trends | JWT | Daily trends |
GET | /api/dashboard/analytics/summary | JWT | Aggregate stats |
ctSignature Developer Manual — Version 1.3
© 2026 CozziTech LLC. All rights reserved.
