Write a document, send it for legally binding signature, publish a tracked link behind whatever gates the deal needs, and read back who read which paragraph and for how long. One REST API, 94 endpoints, scoped keys, signed webhooks.
# 1. Write it, with the people who will sign
curl -X POST "https://xdocs.io/api/v1/documents" \
-H "Authorization: Bearer $XDOCS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Consulting agreement",
"markdown": "# Consulting agreement\n\nThe parties agree…",
"recipients": [{ "name": "Dana Reyes", "email": "dana@example.com" }]
}'
# 2. Give each signer somewhere to sign. Markdown has no fields in it,
# and an envelope refuses a recipient with nothing to complete.
# (Starting from a template instead? It already has them — skip this.)
curl -X POST "https://xdocs.io/api/v1/documents/doc_…/signature-blocks" \
-H "Authorization: Bearer $XDOCS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "heading": "Consultant" }'
# 3. Send it — recipients and fields come from the document
curl -X POST "https://xdocs.io/api/v1/envelopes" \
-H "Authorization: Bearer $XDOCS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "documentId": "doc_…", "sequential": false }'
curl "https://xdocs.io/api/v1/views?documentId=doc_…&limit=20" \
-H "Authorization: Bearer $XDOCS_API_KEY"
# Per-section attention for one document
curl "https://xdocs.io/api/v1/analytics/documents/doc_…" \
-H "Authorization: Bearer $XDOCS_API_KEY"
How it behaves
Authentication
Authorization: Bearer xdk_… on every request. Only a hash of the key is stored, so a lost key is replaced rather than recovered. A key can carry an expiry, and can be revoked at any time.
Scopes
A key holds only what it needs. A reporting integration can be given one that cannot send anything; a CRM writeback can publish links without being able to read every visit. A call outside a key’s scopes answers 403 naming the scope it wanted.
Pagination
Lists take ?limit= (max 100) and ?cursor=, and return nextCursor. Cursors are keyset rather than offset, because a list of visits moves under you while you page through it — offsets skip and repeat, keysets do not. Treat the value as opaque.
Filters
A filter value that is not recognised answers 400, naming what was expected. A mistyped ?status= would otherwise come back as an empty page, and a ?active=yes as an unfiltered one — neither distinguishable from a true answer.
Rate limit
120 requests a minute per key, counted in the database so every instance sees the same total. Every reply carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (Unix seconds); a 429 carries retry-after.
Shapes
One resource comes back as { data }, a list as { data, nextCursor }, a failure as { error }. Timestamps are ISO 8601 in UTC and durations are milliseconds. Deletes answer 204 with no body.
What is never returned
A signing token, a link password, an assignee’s upload token or a storage URL — each is a capability, and handing one to a caller would hand over the thing it protects. Files are streamed through the API instead, and a password is reported as hasPassword.
Scopes
documents:read
Read documents, folders, versions, comments and templates. Also the visits CSV export, and webhook subscriptions with their deliveries and sample payloads.
documents:write
Create and edit documents, folders and templates, upload and replace files, and manage webhook subscriptions.
envelopes:read
Read envelopes, executed PDFs, certificates and returned files.
envelopes:write
Send for signature, remind, void and reassign, and read signing links.
rooms:read
Read data rooms, their state, their folders and what is filed in them.
rooms:write
Create rooms, file documents, manage folders, and freeze or archive a room.
links:read
Read tracked links and the agreements library.
links:write
Publish and change links, manage agreements, answer viewer questions.
views:read
Read visits, visitors, analytics and viewer questions.
audit:read
Read the workspace audit trail.
requests:read
Read file requests and what has arrived.
requests:write
Create file requests and manage who is asked.
When something goes wrong
400
The request could not be understood. A failed body carries `details` with the exact fields.
401
No key, or a key that is revoked, expired or not valid.
402
Payment needed: a send beyond the plan's monthly allowance that the credit balance will not cover. Nothing was charged.
403
A key outside its scopes. The message names the scope that was wanted.
404
No such row — or one belonging to somebody else, which answers the same way on purpose.
409
A conflict with the state: editing a document that is out for signature, voiding an envelope that has ended, changing a frozen room.
422
Understood, and refused: a plan ceiling, an unknown webhook event, a document whose signers have nothing to sign.
429
The rate limit. `retry-after` says how long to wait.
503
Something we depend on — billing, file storage — did not answer. Nothing was done; safe to retry.
Endpoints
Base URL https://xdocs.io/api/v1. Every path below is relative to it.
Discovery
What this key can do and where its links live. The first two calls any integration makes, and both are safe to call from anywhere a key is being checked.
GET/api/v1any key
The catalogue: every resource, the scopes this key holds, the rate limit and the host tracked links are served on.
Query parameters
includestring
`endpoints` adds `groups`: every endpoint with its parameters, the whole of this reference as data.
Returns
{ version, scopes, allScopes, rateLimit{perMinute}, linkOrigin, baseUrl, pagination, documentation, openapi, resources[], webhookEvents[], groups? }. `linkOrigin` is the account's verified custom domain when it has one, so build link URLs from it rather than assuming ours.
GET/api/v1/meany key
Who this key acts for: the workspace, its plan and the key's scopes.
This same surface as an OpenAPI 3.1 document, for generating a client.
Returns
An OpenAPI document. No key required, and not counted against a rate limit.
Documents
The editable source of truth. An authored document carries its body as structured content; an uploaded one carries a PDF; a shared file carries any other type, uploaded in the app. Authored documents and PDFs take a signing list; all three take share links, rooms and workspace folders.
GET/api/v1/documentsdocuments:read
Your documents, most recently edited first.
Query parameters
qstring
Searches titles and body text.
statusstring
`draft` or `locked` (out for signature). Anything else is a 400.
kindstring
`xdoc` for authored, `pdf` for uploaded, `file` for a shared file. Anything else is a 400.
folderIdstring
Documents filed in one workspace folder.
inLibraryboolean
`true` for the curated library only.
trashedboolean
`true` lists deleted documents instead of live ones.
limitinteger
Rows per page, up to 100. Defaults to 50.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
Create a document from Markdown, HTML, document JSON, or one of your saved templates.
Body (JSON)
titlestring
1–200 characters. Required, unless `templateId` is given — then it defaults to the template's name.
markdownstring
The body most callers have, up to 500,000 characters. Headings, both list kinds, quotes, code fences, rules, GFM pipe tables and `$…$` / `$$…$$` mathematics are all read; images are not. Converted on the way in, and conversion losses come back in `warnings`.
htmlstring
An alternative body, for callers holding HTML. Up to 1,000,000 characters.
contentobject
Raw document JSON with `type: "doc"`, for callers holding that. Up to 1,000,000 characters serialized.
templateIdstring
Start from a saved template, signature blocks already in place. Cannot be combined with a body. Any `recipients` sent alongside fill the template's slots in order, so those blocks stay bound to them; a recipient who omits `role` or `routingOrder` keeps the slot's.
The draft signing list, up to 30. `role` is `signer` (the default) or `viewer`; `routingOrder` 1–99, defaulting to 1; `accessCode` (4–40 characters) is an out-of-band code that person must enter before signing.
folderIdstring
File it in one of your workspace folders.
Returns
201 { data: Document, warnings: string[] } — the document in the shape GET /documents/:id returns
400 No title for a document that is not from a template, or a template and a body in the same request.
404 No such template.
422 The plan's document ceiling has been reached, or the folder is not in your workspace.
POST/api/v1/documents/uploaddocuments:write
Create a document from a PDF.
Query parameters
titlestring
Overrides the filename as the title.
Body (multipart or raw)
filemultipart file field
Or send the raw PDF as the body with an `x-filename` header. 25 MB maximum.
Returns
201 { data: Document & { hasTextLayer } } — `hasTextLayer` false means a scan whose words cannot be read or searched.
400 Not a PDF, unreadable, or has no pages.
422 Larger than 25 MB, or the plan's ceiling has been reached.
GET/api/v1/documents/:iddocuments:read
One document, with its signing list and what file sits under it.
Query parameters
includestring
`content` adds the body, which is the largest thing stored and so is opt-in.
Returns
{ data: Document & { folderId, inLibrary, deletedAt, recipients: [{ id, name, email, role, routingOrder, color, hasAccessCode }], source{filename, contentType, byteSize, hasTextLayer} | null, content? } } — an access code is reported as set, never repeated back
PATCH/api/v1/documents/:iddocuments:write
Change a draft. A new body replaces the old one, which is snapshotted to version history first.
Replaces the signing list, up to 30. Send back a recipient's `id` — or their unchanged address — to correct a name or email in place: their id is what the signature fields in the body are bound to, and a recipient who arrives without one is treated as somebody new, with nothing yet to sign.
folderIdstring | null
A workspace folder, or null to take it out of one.
inLibraryboolean
Add it to or remove it from the curated library.
versionLabelstring
Names the snapshot taken before the body is replaced. Up to 120 characters.
Returns
{ data: Document, warnings: string[] } — the document in the shape GET /documents/:id returns
409 A new body for a document out for signature. Void the envelope first.
422 A body for an uploaded file, which is replaced with POST /documents/:id/file instead; or a folder that is not in your workspace.
DELETE/api/v1/documents/:iddocuments:write
Move a document to the trash, where it can be restored.
Query parameters
purgeboolean
`true` deletes it outright, and only from the trash.
Returns
204
409 It is out for signature; or a purge was asked for on a document that is not in the trash, or that has been sent for signature and is kept as part of that record.
POST/api/v1/documents/:id/restoredocuments:write
Bring a document back from the trash.
Returns
{ data: Document }
409 That document is not in the trash.
422 The plan's document ceiling has been reached; trashed documents do not count toward it.
GET/api/v1/documents/:id/filedocuments:read
The file under an uploaded document: the current source PDF, or a shared file (`kind: file`) in its own type.
Returns a file
The file bytes, with the stored content type and filename.
404 An authored document, which has no source file — export it instead.
POST/api/v1/documents/:id/filedocuments:write
Replace the file under an uploaded document. It keeps its id and every link to it; the outgoing file becomes a version.
Body (multipart or raw)
filemultipart file field
Or the raw PDF as the body with an `x-filename` header. 25 MB maximum.
Returns
{ data: { documentId, previousVersionId, pageCount, previousPageCount, hasTextLayer, droppedFields } } — `previousVersionId` holds the outgoing file; `droppedFields` counts field placements on pages the new file no longer has
400 Not a PDF, unreadable, or has no pages.
409 The document is out for signature, or is not an uploaded PDF.
422 Larger than 25 MB.
503 The file could not be stored. Nothing changed; safe to retry.
GET/api/v1/documents/:id/exportdocuments:read
The document as a file, typeset the way the app exports it.
Query parameters
formatstring
`pdf` (default), `docx` or `md`. Anything else is a 400.
Returns a file
The file bytes.
409 An uploaded PDF can only be exported as PDF.
GET/api/v1/documents/:id/versionsdocuments:read
History, newest first: the restore points somebody named and the snapshots the document took of itself.
Query parameters
limitinteger
Up to 100. Defaults to 50.
Returns
{ data: Version[] } — id, documentId, title, kind, pageCount, label, automatic, createdBy, createdAt. `automatic` is true for the snapshots the document took of itself, which have no label.
POST/api/v1/documents/:id/versionsdocuments:write
Mark a restore point at the document as it stands.
Body (JSON)
labelstring
What this point is, up to 120 characters. Defaults to naming the API.
409 A tracked link is pinned to this version; `details.links` names them. Re-point them first — an unpinned link quietly starts showing the live document.
Append somewhere to sign — name, signature and date, bound to one recipient. The step between writing a document through the API and being able to send it, because an envelope refuses to go to a recipient with nothing to complete.
Body (JSON)
recipientIdstring
A recipient on the document. Optional when there is only one.
emailstring
Identify that recipient by address instead.
headingstring
The label above the block, usually the party's role in the agreement. Up to 120 characters; defaults to the recipient's name.
Returns
201 { data: Document, addedFor: { recipientId, name, email }, totalFields } — the body before the block is snapshotted to version history first; `totalFields` counts every field now in the body, across all recipients
409 The document is out for signature.
422 No recipients yet, a recipient who is not on the document, several recipients and none named, or an uploaded PDF — whose fields are placed by page coordinate in the editor.
GET/api/v1/documents/:id/commentsdocuments:read
The comment thread, oldest first.
Query parameters
resolvedboolean
`false` for what is still open.
Returns
{ data: Comment[] } — id, documentId, parentId, authorName, authorEmail, body, quote, resolved, createdAt. A reply carries the `parentId` of the comment that opened its thread.
POST/api/v1/documents/:id/commentsdocuments:write
Leave a comment. The owner, the thread and anyone @-mentioned are emailed, as they are from the editor.
Body (JSON)
bodystringrequired
Up to 10,000 characters. `@name` mentions are notified.
parentIdstring
Reply into an existing thread: the id of the comment that opened it. Threads are one level deep.
quotestring
The passage being commented on, up to 2,000 characters.
Returns
201 { data: Comment, notified: string[] } — `notified` is the addresses emailed
422 `parentId` is not a comment on this document, or is itself a reply.
403 The comment was written by somebody else. Reply to it or resolve the thread instead.
GET/api/v1/foldersdocuments:read
The workspace's document folders, in the order the dashboard shows them — where the `folderId` a document is filed under comes from. Folders nest; the whole tree comes back in one call, so build it from `parentId` rather than asking per level.
Returns
{ data: Folder[] } — id, name, parentId (null at the top level), position, documentCount (live documents filed directly in it, not in its subfolders), createdAt, updatedAt
POST/api/v1/foldersdocuments:write
A folder, after the existing ones in the same place. File documents into it with `folderId` on POST or PATCH /documents.
Body (JSON)
namestringrequired
1–120 characters.
parentIdstring | null
Nest it inside one of your folders. Omit for the top level.
Returns
201 { data: Folder }
404 No such parent folder in your workspace.
422 That would nest the folder more than 8 levels deep.
PATCH/api/v1/folders/:iddocuments:write
Rename a folder, reorder it among its siblings, or move it — with everything under it — into another folder.
Body (JSON)
namestring
1–120 characters.
positioninteger 0–10,000
Orders it among the folders sharing its parent.
parentIdstring | null
Move it inside another folder, or null for the top level. Omit to leave it where it is.
Returns
{ data: Folder }
404 No such folder, or no such parent folder in your workspace.
422 A move into the folder itself or into one of its own subfolders, or one that would take the branch more than 8 levels deep.
DELETE/api/v1/folders/:iddocuments:write
Remove a folder. Its documents and its subfolders move up a level, into whatever it was itself inside, rather than going with it.
Returns
204
Templates
A document saved for reuse. Recipient slots and the fields bound to them are kept; the addresses and access codes are stripped, so a template can never quietly send to whoever signed last time.
GET/api/v1/templatesdocuments:read
Saved templates, most recently changed first.
Query parameters
limitinteger
Rows per page, up to 100. Defaults to 50.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
1–200 characters. Defaults to the document's title plus “template”.
descriptionstring
Up to 2,000 characters.
Returns
201 { data: Template }
PATCH/api/v1/templates/:iddocuments:write
Rename or re-describe a template. Its body is fixed at save time.
Body (JSON)
namestring
1–200 characters.
descriptionstring
Up to 2,000 characters.
Returns
{ data: Template }
DELETE/api/v1/templates/:iddocuments:write
Delete a template. Documents made from it are untouched.
Returns
204
Envelopes
A document out for signature. Sending freezes a snapshot, so later edits cannot change what somebody agreed to, and the completed PDF carries a certificate with the whole audit trail.
GET/api/v1/envelopesenvelopes:read
Envelopes you sent, newest first, each with its parties.
Query parameters
statusstring
`sent`, `completed`, `declined`, `voided` or `expired`. Anything else is a 400.
limitinteger
Rows per page, up to 100. Defaults to 50.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns
{ data: Envelope[], nextCursor } — id, documentId, title, kind, pageCount, subject, message, senderName, senderEmail, status, sequential, contentHash, completionHash, expiresAt, reminderEveryDays, lastReminderAt, sentAt, completedAt, voidedAt, voidReason, recipients: [{ id, name, email, role, routingOrder, status, viewedAt, signedAt, declinedAt, declineReason, notifiedAt, deliveryFailedAt, deliveryError, authMethod, authenticatedAt, delegatedToEmail, delegatedFromId }]. `deliveryFailedAt` is set when the signing email bounced; `authMethod` is `email` or `email+access_code`, and the code itself is never returned.
POST/api/v1/envelopesenvelopes:write
Send a prepared document for signature. Recipients and field placement come from the document, because that is where they are authored.
Body (JSON)
documentIdstringrequired
A draft with at least one signer, each with a name, an address and at least one field to complete — see POST /documents/:id/signature-blocks.
subjectstring
The email subject, up to 300 characters. Defaults to “Please sign: <title>”.
messagestring
A note inside the email, up to 2,000 characters.
sequentialboolean
True routes signers one after another; false sends to everybody at once.
expiresAtISO 8601 datetime
After this the envelope stops accepting signatures.
reminderEveryDaysinteger 1–30
Nudge whoever is holding it up on this cadence.
Returns
201 { data: Envelope, delivery: [{ email, delivered, reason }] } — check `delivery`: the envelope is real and its links work even when a notification bounced.
409 That document is already out for signature — including when a concurrent send claimed it first, so a retried request cannot mail one agreement twice.
404 No such document.
422 Not ready to send: no signers, a recipient missing a name or address, a signer with no field to complete, a field bound to a recipient no longer on the list, unresolved suggestions in the body, a placeholder left in it (`[COMPANY NAME]`, “TBD”) or a signature line with no field — or a shared file, which cannot be signed. The message names which.
402 The plan's sends for this month are used and the credit balance will not cover another. Nothing was charged; top up on the billing page or upgrade the plan.
503 Billing did not answer, so nothing was sent or charged. Safe to retry.
GET/api/v1/envelopes/:idenvelopes:read
Full status, with every party and every field.
Query parameters
includestring
`signingUrls` adds each outstanding recipient's signing link. That link is the capability to sign, so it needs the envelopes:write scope.
Returns
{ data: Envelope & { recipients[], fields: [{ id, recipientId, type, label, required, completedAt }] } } — with `signingUrls`, each recipient also carries `signingUrl`, null for anyone who has signed, declined or passed it on
403 Signing links were asked for by a key that cannot send.
POST/api/v1/envelopes/:id/remindenvelopes:write
Nudge whoever the envelope is waiting on — on a sequential envelope that is one person, not everybody unsigned.
Returns
{ data: Envelope, reminded: number }
409 The envelope has ended, or nobody is currently waiting.
POST/api/v1/envelopes/:id/voidenvelopes:write
Cancel an envelope in flight. Everyone outstanding is told the reason and the document returns to draft.
Body (JSON)
reasonstringrequired
Up to 500 characters. Sent to the recipients and written to the audit trail.
The same evidence as data: signers, consent records, authentication, the audit trail and the integrity digests.
Returns
{ data: Certificate } — the digests are recomputed from the stored rows and reported as `documentHashVerified` / `completionHashVerified`, not merely echoed back.
{ data: Attachment[] } — id, envelopeId, recipientId, fieldId, filename, contentType, byteSize, sha256, uploadedAt, newest first. `sha256` is the digest of the bytes as received.
The bytes of one returned file, streamed through rather than exposed as a storage URL.
Returns a file
The file bytes.
Share links
A tracked link to one document or one room, with its own gates. Every gate the share dialog offers is here under the same name the response returns it with, so you can read a link, change one field and send it back.
GET/api/v1/linkslinks:read
Tracked links, newest first, each with its URL on your link origin.
Query parameters
documentIdstring
Links to one document.
dataRoomIdstring
Links to one room.
activeboolean
`true` for links that still answer.
limitinteger
Rows per page, up to 100. Defaults to 50.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns
{ data: Link[], nextCursor }
POST/api/v1/linkslinks:write
Publish a link to one document or one room.
Body (JSON)
documentIdstring
Exactly one of this and `dataRoomId`.
dataRoomIdstring
Exactly one of this and `documentId`.
namestring
How the link is labelled in analytics. Defaults to “Shared link”.
activeboolean
Defaults to true.
isPublicboolean
Publish the document: anyone with the address reads it immediately, and search engines are asked to index the page. Forces `requireEmail`, `requireEmailVerification`, `password` and `requireNda` off, whatever else the same request sets. Defaults to false, so a link is private unless you say otherwise.
requireEmailboolean
Ask a visitor who they are. Defaults to true, and ignored when `isPublic` is set.
requireEmailVerificationboolean
Send a six-digit code to that address and check it. Implies `requireEmail`.
passwordstring | null
Protect the link. Null removes it.
expiresAtISO 8601 datetime | null
maxViewsinteger | null
Stop answering after this many visits.
pinnedVersionIdstring | null
Serve one saved version rather than the live document.
allowDownloadboolean
Defaults to false.
watermarkboolean
Defaults to true.
watermarkConfigobject | null
`{ text, position, opacity, rotation, fontSize, color }`, all optional and merged over the current values. `text` takes the placeholders {email}, {date}, {time}, {ip} and {link}; `position` is tiled, center or footer; `opacity` 1–100; `rotation` -90–90; `fontSize` 8–96; `color` a six-digit hex. Null clears it back to the default.
requireNdaboolean
Ask the visitor to accept terms before they can read.
ndaTextstring
Terms written on the link itself.
agreementIdstring | null
Terms from your agreements library instead. Editing that agreement re-asks everyone who accepted the old wording.
allowedEmailsstring[]
Only these addresses — or anyone at `allowedDomains`, when both are set. Up to 200 entries.
allowedDomainsstring[]
Only addresses at these domains, matched exactly: `acme.com` does not admit `eu.acme.com`. Up to 200.
blockedEmailsstring[]
Never these. An entry without an `@` in the middle — `rival.com` or `@rival.com` — is a domain and blocks everybody at it. Up to 200.
notifyOnViewboolean
Email you when somebody opens it. Defaults to true.
screenshotProtectionboolean
Deter casual copying: printing is blocked and the page hides when the window loses focus. Defaults to false.
allowAssistantboolean
Let the reader ask questions about the document. Defaults to false.
allowAudioboolean
Let the reader have the document read aloud, choosing a voice and a speed. Defaults to false.
allowFeedbackboolean
Show a reaction bar under the document. Defaults to false.
bulkDownloadOtpboolean
Room links only: make a visitor confirm an emailed code before a zip of everything. Defaults to true.
tagsstring[]
Your own labels for filtering links: up to 20, each one word of up to 32 characters, stored lowercased.
Extra questions asked with the email, up to 8. `type` is text, select or checkbox and defaults to text; a select needs `options`. `id` is minted from the label when you omit it, and is the key the answers come back under on a view.
ogTitlestring | null
What a chat app or social card shows when the link is pasted. Defaults to the document or room name.
ogDescriptionstring | null
The line under that title.
ogImageUrlstring | null
The card's picture, an http or https address. Defaults to the XDocs card. 1200×630 previews best.
ogFaviconUrlstring | null
The icon in the browser tab on the viewer page.
Returns
201 { data: Link } — including `url`, `slug` and `watermarkConfig` as resolved
422 A pinned version or agreement that is not yours, or the plan's link ceiling has been reached.
GET/api/v1/links/:idlinks:read
One link and its gates. The password is reported as `hasPassword`, never returned.
Returns
{ data: Link } — id, name, slug, url, documentId, dataRoomId, groupId, viewCount, hasPassword, createdAt, updatedAt, and every setting POST accepts under the same name. `ndaText` is null while an `agreementId` supplies the terms. `groupId` marks a room link made in the app for one viewer group: it admits only that group's members and shows only the files the group was given, whatever `allowDownload` says.
PATCH/api/v1/links/:idlinks:write
Change any setting; omitted fields are kept. Tightening a gate re-gates visitors who already cleared it — a new password or new terms are asked for again, and the address lists are re-checked on every visit, so a newly blocked address is turned away on its next one.
Body (JSON)
…same as POST
Every setting POST accepts, all optional. `documentId` and `dataRoomId` are not among them: a link points at what it was made for.
Returns
{ data: Link }
422 A pinned version that is not this document's (or any pin on a room link), an agreement that is not in your library, or an `ogImageUrl` / `ogFaviconUrl` that is not http or https. A viewer-group link cannot be made public, or switched back on once its group has been deleted.
DELETE/api/v1/links/:idlinks:write
The link stops answering. Its visits are kept, because they are evidence about who read what.
Returns
204
Data rooms
Many documents behind one set of gates, in folders, with reading attributed per file. A frozen room's contents are locked while its links keep serving them; an archived room is retired, and every link into it answers with a closed page until it is restored. Viewer groups, which narrow a room link to some of its files, are set up in the app.
GET/api/v1/roomsrooms:read
Your data rooms, most recently changed first.
Query parameters
archivedboolean
`false` for the rooms the app's room list shows, `true` for the archived ones. Omitted, both.
limitinteger
Rows per page, up to 100. Defaults to 50.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
Remove a folder. Its documents return to the top level rather than leaving the room.
Returns
204
409 The room is frozen. Unfreeze it with PATCH /rooms/:id to change its contents.
Agreements
Confidentiality terms written once and picked by name on any link. Editing one re-asks everybody who accepted the old wording, while their record keeps the text they actually agreed to.
GET/api/v1/agreementslinks:read
The library, most recently edited first, with how much each one is used.
20–20,000 characters. The exact text a visitor is asked to accept.
requireNameboolean
Also ask the visitor to type their name. Defaults to false.
Returns
201 { data: Agreement }
PATCH/api/v1/agreements/:idlinks:write
Change the wording. New wording is a new agreement for everyone who accepted the old one.
Body (JSON)
namestring
1–120 characters. Renaming re-asks nobody.
bodystring
20–20,000 characters.
requireNameboolean
Returns
{ data: Agreement, regatedLinks: number } — `regatedLinks` is how many links now ask again, and is 0 unless the wording itself changed
DELETE/api/v1/agreements/:idlinks:write
Remove from the library. Links using it keep the gate, with the text copied onto them, so deleting never quietly opens a document; link presets that named it keep its words the same way.
Returns
204
Views and visitors
Reading measured section by section. Time accrues only while a section is on screen and the tab is active, so these are attention numbers rather than tab-left-open numbers.
GET/api/v1/viewsviews:read
Visits, newest first. A visit counts once every gate was cleared.
Query parameters
documentIdstring
dataRoomIdstring
shareLinkIdstring
emailstring
sinceISO 8601 datetime
Visits started at or after this.
includeUnopenedboolean
`true` also lists visits that never cleared the gates.
limitinteger
Rows per page, up to 100. Defaults to 50.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns
{ data: View[], nextCursor } — id, shareLinkId, documentId, dataRoomId, viewerEmail, emailVerified, ndaAcceptedAt, ndaText (the exact terms accepted, whatever the link says now), ndaSignedName, openedAt (null until every gate was cleared), lastOpenedAt, startedAt, lastSeenAt, totalMs (active reading, not wall clock), opens (separate sittings), completion, downloadedAt, captureAnswers (keyed by the link's capture field `id`), country, city, referrer, userAgent, ipAddress
400 `since` is not a timestamp.
GET/api/v1/views/:idviews:read
One visit with its per-section reading — per page for a PDF.
Returns
{ data: View & { sections: [{ documentId, index, heading, visibleMs }] } } — a room visit carries several documents' sections, told apart by `documentId`
GET/api/v1/visitorsviews:read
Visits grouped by the person who made them: not “opened nine times” but “this person has read four documents for an hour”.
Query parameters
documentIdstring
dataRoomIdstring
shareLinkIdstring
emailstring
One visitor, by the address they gave.
sinceISO 8601 datetime
limitinteger
Rows per page, up to 100. Defaults to 50.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns
{ data: Visitor[], nextCursor } — email, views, documents, totalMs, avgCompletion, firstSeenAt, lastSeenAt, verified, acceptedNda, downloaded. Only visits that captured an address and cleared the gates are counted.
Every counted visit as CSV, one row per visit per document read — the file the settings page offers.
Query parameters
fromISO 8601 date or datetime
Visits from this moment.
toISO 8601 date or datetime
Visits up to this moment. A bare date covers the whole of that day.
Returns a file
A CSV file.
Analytics
The headline numbers behind the analytics page, and the attention profile of a single document.
GET/api/v1/analytics/summaryviews:read
Views against the previous period, visitors, reading time, completion, the top documents, rooms, links and visitors, devices, and a per-day series.
Query parameters
daysinteger
7, 30 or 90. Defaults to 30. Anything else is a 400.
Returns
{ data: { days, since, current, previous, series[], topDocuments[], topRooms[], topLinks[], topVisitors[], recent[], devices, anonymousShare } } — `series` has one entry per day, oldest first; `anonymousShare` is the share of views (0–100) with no address captured
GET/api/v1/analytics/documents/:idviews:read
Where readers spent their attention in one document, section by section — per page for a PDF — including visits that arrived through a room.
Query parameters
shareLinkIdstring
Only visits through one link.
Returns
{ data: { documentId, title, kind, unit, views, measuredViews, totalMs, sections: [{ index, heading, words, seen, readers, totalMs, medianMs, msPerHundredWords, friction, stoppedHere, reached, reachedPct }] } } — `unit` is `section` or `page`; `seen` counts visits that dwelt on a section at all and `readers` those that read it; `friction` is pace against the document's own median, where 1 is typical
Viewer questions
What readers asked from inside a shared link, and your answers, which are emailed back to them.
GET/api/v1/questionsviews:read
Question threads, those waiting on you first. The two hundred most recently opened; a specific thread is always reachable by id.
Query parameters
awaitingboolean
`true` for threads where the reader spoke last.
documentIdstring
dataRoomIdstring
shareLinkIdstring
emailstring
Returns
{ data: Thread[] } — id, documentId, dataRoomId, shareLinkId, linkName, about (the document or room title), askedByEmail, awaitingOwner, createdAt, lastMessageAt, messages: [{ id, author, body, createdAt, delivered }], opening question first
GET/api/v1/questions/:idviews:read
One thread, every turn in order.
Returns
{ data: Thread }
POST/api/v1/questions/:id/replylinks:write
Answer in the thread. Emailed to the asker when the link captured an address; `delivered` says whether it went.
Body (JSON)
bodystringrequired
Up to 5,000 characters.
Returns
{ data: Thread, delivered: boolean } — the reply is kept in the thread whether or not the email went
422 The reply could not be recorded; the message says why.
File requests
Ask named people for named documents. Each assignee gets their own upload link, and whatever arrives becomes a document in the workspace, filed where the request says.
GET/api/v1/requestsrequests:read
File requests, most recently changed first, with progress counts.
Returns
{ data: Request[] } — id, slug, url, title, description, dataRoomId, folderId, requireEmail, dueAt, remindEveryDays, notifyOnUpload, active, closedAt, createdAt, updatedAt, counts{items, assignees, completed, uploads}. `url` is the open upload page; each assignee is mailed their own.
GET/api/v1/requests/:idrequests:read
The request with its items, assignees and what has arrived.
Returns
{ data: Request & { items: [{ id, name, description, required, position }], assignees: Assignee[], uploads: [{ id, itemId, assigneeId, uploaderEmail, uploaderName, filename, contentType, byteSize, sha256, documentId, uploadedAt }] } } — an upload is read as the document named by `documentId`; assignee tokens and storage URLs are never included
POST/api/v1/requestsrequests:write
Ask for files. Assignees are mailed their own link at once.
Body (JSON)
titlestringrequired
1–200 characters.
descriptionstring
Up to 5,000 characters.
items{name, description?, required?}[]
What is wanted, up to 50. `required` defaults to true.
dataRoomIdstring | null
Room uploads are filed into.
folderIdstring | null
A folder in that room. Needs `dataRoomId`.
dueAtISO 8601 datetime | null
remindEveryDaysinteger 1–60 | null
Reminder cadence for whoever has not finished.
requireEmailboolean
Ask an anonymous uploader who they are. Defaults to true.
notifyOnUploadboolean
Email you when something arrives. Defaults to true.
Change any setting. `active: false` closes it, and the link says so instead of accepting files.
Body (JSON)
…same as POST
`title`, `description`, `dueAt`, `remindEveryDays`, `requireEmail`, `notifyOnUpload` and `active`, all optional. The room, folder, items and assignees are fixed once a request exists.
Returns
{ data: Request }
DELETE/api/v1/requests/:idrequests:write
Delete the request. Uploads stay: they are documents in the workspace now.
Returns
204
POST/api/v1/requests/:id/assigneesrequests:write
Add people and mail each their link. Idempotent per address: somebody already on the request has their name updated and is mailed again rather than added twice.
Body (JSON)
assignees{email, name?}[]required
1–50 people.
Returns
201 { data: Assignee[] } — every assignee on the request: id, email, name, delivery, deliveryError, sentAt, remindedAt, reminderCount, uploadedAt, completedAt, createdAt
Mail their link again — a fresh invitation if the first never arrived, otherwise a reminder.
Returns
{ data: Assignee, delivered: boolean }
404 No such assignee on this request.
Webhooks
Subscriptions an integration creates and removes on its own, one per thing it wants to hear about. This is the shape Zapier, Make and n8n expect, and a subscription is the same endpoint row the developer settings page shows with its delivery log — so these calls see and change the workspace's endpoints wherever they were made.
GET/api/v1/hooksdocuments:read
Every webhook endpoint in the workspace, newest first — including those added on the settings page — and the catalogue of events.
Returns
{ data: Hook[], events: [{ name, description }] } — `events` includes `ping`, the test event
POST/api/v1/hooksdocuments:write
Subscribe a URL. Re-subscribing the same URL to the same events returns the existing row rather than a duplicate, because platforms retry.
Body (JSON)
urlstringrequired
Must be https.
eventsstring[]
Event names, up to 50. Empty, with no `event` either, means every event.
eventstring
A single event, for platforms that subscribe one at a time. Merged with `events`.
Returns
201 { data: Hook & { secret } } — the signing secret. An identical re-subscribe answers 200 with the existing row and its secret, and switches it back on if it had been switched off.
422 Unknown event names; `details.known` lists the valid ones.
GET/api/v1/hooks/:iddocuments:read
One subscription, with its recent health.
Returns
{ data: Hook } — id, url, events, active, failureCount, lastStatus, lastAttemptAt, createdAt. `failureCount` is consecutive failed deliveries; at 10 the endpoint is switched off.
PATCH/api/v1/hooks/:iddocuments:write
Change the URL or events, or switch it on and off. Switching it on resets the failure count.
Body (JSON)
urlstring
Must be https.
eventsstring[]
Replaces the list. Empty means every event.
activeboolean
Returns
{ data: Hook }
422 Unknown event names; `details.known` lists the valid ones.
DELETE/api/v1/hooks/:iddocuments:write
Unsubscribe. Idempotent: a second call is a 204 too.
Returns
204
POST/api/v1/hooks/:id/pingdocuments:write
Send a `ping` delivery now, whatever the endpoint subscribes to and even if it is switched off, and report how it answered.
Recent real payloads for one event, for a platform's “load sample” step. A synthetic one of the same shape when nothing has happened yet.
Query parameters
eventstring
The event name.
Returns
{ data: object[] } — up to three delivered bodies, each `{ id, event, createdAt, data }`
422 Unknown event name; `details.known` lists the valid ones.
Audit log
Every recorded event across signing, sharing and documents. This is the evidence trail — what happened, to what, by whom, from where — as opposed to analytics, which is about attention.
GET/api/v1/auditaudit:read
The trail, newest first.
Query parameters
familystring
`signing`, `sharing` or `documents`. Anything else is a 400.
typestring
One event type, e.g. `recipient.signed` — see GET /audit/types. An unknown one is a 400.
qstring
Matches the description, actor name or actor email.
fromISO 8601 datetime
toISO 8601 datetime
documentIdstring
envelopeIdstring
shareLinkIdstring
One link's history, including after the link was deleted.
limitinteger
Up to 500. Defaults to 100, or 500 with `format=csv`.
cursorstring
The previous page's `nextCursor`. Opaque — pass it back unchanged.
Rather than polling, subscribe a URL and hear about things as they happen. Each delivery is a POST of { id, event, createdAt, data } carrying x-xdocs-event, x-xdocs-event-id, x-xdocs-timestamp and x-xdocs-signature, a hex HMAC-SHA256 of {timestamp}.{body} using the secret returned when you subscribe. Verify the raw body before parsing it. Answer with any 2xx within eight seconds. A delivery is tried up to three times with a short backoff — a 4xx other than 429 is taken as a refusal and not retried — and an endpoint that fails 10 deliveries in a row is switched off with its log kept.
A visitor accepted the link's confidentiality terms.
link.downloaded
A visitor downloaded a PDF copy.
link.bulk_downloaded
A visitor downloaded a room or folder as a zip.
ping
A test delivery sent from the settings page.
For AI agents
Give a model the whole API
All 94 endpoints, the authentication, the pagination and the rules that are not expressible in a schema — as one block of text to paste, a URL to fetch, a spec to generate a client from, or a tool server your agent can call directly.
The complete reference, as text
Roughly what a model needs and nothing it does not: the base URL, the header, the scopes, the pagination, and every endpoint with its parameters. Paste it into a system prompt or a context file.
# XDocs API
XDocs writes agreements, sends them for signature, publishes tracked links to them, and measures who read what. This is its HTTP API, complete.
Base URL: https://xdocs.io/api/v1
Auth: `Authorization: Bearer xdk_…` on every request. Create a key under Settings → Developer.
Rate limit: 120 requests a minute per key. Replies carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (Unix seconds); a 429 carries retry-after.
Pagination: lists take ?limit= (max 100) and ?cursor=, where cursor is the previous page's nextCursor and is opaque.
Filters are strict: a value a filter does not recognise — a mistyped ?status=, or a ?resolved= that is not exactly true or false — answers 400. No filter silently returns an empty page or an unfiltered one.
Shapes: `{ data }` for one resource, `{ data, nextCursor }` for a list, `{ error }` on failure with `details` when a body failed validation.
Timestamps: ISO 8601, UTC. Durations: milliseconds.
Scopes: a key holds some of documents:read, documents:write, envelopes:read, envelopes:write, rooms:read, rooms:write, links:read, links:write, views:read, audit:read, requests:read, requests:write. A call outside them answers 403 naming the scope it needed.
Errors beyond 400/401/403/404: 409 is a conflict with the current state, 422 a request understood and refused (a plan ceiling, a document not ready to send), 402 a send the credit balance cannot cover, 503 a dependency that did not answer — nothing was done, retry.
Rules worth knowing before you call anything:
- Sending an envelope freezes a snapshot of the document. Editing the document afterwards cannot change what a signer was shown.
- A document out for signature has status `locked` and refuses edits until its envelope is voided.
- Replacing a body through PATCH /documents/:id snapshots the previous one to version history first, so an overwrite is recoverable.
- Deleting a document moves it to the trash; `?purge=true` deletes it, and only from the trash, and never once it has been sent for signature.
- Tightening a link's gates re-gates visitors who already cleared them.
- Signing links are capabilities. They are only returned from GET /envelopes/:id?include=signingUrls, and only to a key that can send.
- A document written from Markdown has no signature fields in it. Add one per signer with POST /documents/:id/signature-blocks before sending, or start from a template, which already has them.
- A frozen data room refuses every change to what is filed in it (409) until PATCH /rooms/:id sends `frozen: false`. An archived room's links all answer with a closed page.
- A `folderId` comes from GET /folders; a room folder's id comes from GET /rooms/:id/folders. They are different things.
## Discovery
What this key can do and where its links live. The first two calls any integration makes, and both are safe to call from anywhere a key is being checked.
### GET /api/v1
Scope: any valid key
The catalogue: every resource, the scopes this key holds, the rate limit and the host tracked links are served on.
Query:
- include (string) — `endpoints` adds `groups`: every endpoint with its parameters, the whole of this reference as data.
Returns: { version, scopes, allScopes, rateLimit{perMinute}, linkOrigin, baseUrl, pagination, documentation, openapi, resources[], webhookEvents[], groups? }. `linkOrigin` is the account's verified custom domain when it has one, so build link URLs from it rather than assuming ours.
### GET /api/v1/me
Scope: any valid key
Who this key acts for: the workspace, its plan and the key's scopes.
Returns: { data: { workspaceId, name, email, scopes, plan, origin } }
### GET /api/v1/openapi.json
Scope: none — no key needed
This same surface as an OpenAPI 3.1 document, for generating a client.
Returns: An OpenAPI document. No key required, and not counted against a rate limit.
## Documents
The editable source of truth. An authored document carries its body as structured content; an uploaded one carries a PDF; a shared file carries any other type, uploaded in the app. Authored documents and PDFs take a signing list; all three take share links, rooms and workspace folders.
### GET /api/v1/documents
Scope: documents:read
Your documents, most recently edited first.
Query:
- q (string) — Searches titles and body text.
- status (string) — `draft` or `locked` (out for signature). Anything else is a 400.
- kind (string) — `xdoc` for authored, `pdf` for uploaded, `file` for a shared file. Anything else is a 400.
- folderId (string) — Documents filed in one workspace folder.
- inLibrary (boolean) — `true` for the curated library only.
- trashed (boolean) — `true` lists deleted documents instead of live ones.
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: Document[], nextCursor } — id, title, kind, status, pageCount, activeEnvelopeId, createdAt, updatedAt
### POST /api/v1/documents
Scope: documents:write
Create a document from Markdown, HTML, document JSON, or one of your saved templates.
Body (JSON):
- title (string) — 1–200 characters. Required, unless `templateId` is given — then it defaults to the template's name.
- markdown (string) — The body most callers have, up to 500,000 characters. Headings, both list kinds, quotes, code fences, rules, GFM pipe tables and `$…$` / `$$…$$` mathematics are all read; images are not. Converted on the way in, and conversion losses come back in `warnings`.
- html (string) — An alternative body, for callers holding HTML. Up to 1,000,000 characters.
- content (object) — Raw document JSON with `type: "doc"`, for callers holding that. Up to 1,000,000 characters serialized.
- templateId (string) — Start from a saved template, signature blocks already in place. Cannot be combined with a body. Any `recipients` sent alongside fill the template's slots in order, so those blocks stay bound to them; a recipient who omits `role` or `routingOrder` keeps the slot's.
- recipients ({name, email, role?, routingOrder?, accessCode?}[]) — The draft signing list, up to 30. `role` is `signer` (the default) or `viewer`; `routingOrder` 1–99, defaulting to 1; `accessCode` (4–40 characters) is an out-of-band code that person must enter before signing.
- folderId (string) — File it in one of your workspace folders.
Returns: 201 { data: Document, warnings: string[] } — the document in the shape GET /documents/:id returns
400: No title for a document that is not from a template, or a template and a body in the same request.
404: No such template.
422: The plan's document ceiling has been reached, or the folder is not in your workspace.
### POST /api/v1/documents/upload
Scope: documents:write
Create a document from a PDF.
Query:
- title (string) — Overrides the filename as the title.
Body (JSON):
- file (multipart file field) — Or send the raw PDF as the body with an `x-filename` header. 25 MB maximum.
Returns: 201 { data: Document & { hasTextLayer } } — `hasTextLayer` false means a scan whose words cannot be read or searched.
400: Not a PDF, unreadable, or has no pages.
422: Larger than 25 MB, or the plan's ceiling has been reached.
### GET /api/v1/documents/:id
Scope: documents:read
One document, with its signing list and what file sits under it.
Query:
- include (string) — `content` adds the body, which is the largest thing stored and so is opt-in.
Returns: { data: Document & { folderId, inLibrary, deletedAt, recipients: [{ id, name, email, role, routingOrder, color, hasAccessCode }], source{filename, contentType, byteSize, hasTextLayer} | null, content? } } — an access code is reported as set, never repeated back
### PATCH /api/v1/documents/:id
Scope: documents:write
Change a draft. A new body replaces the old one, which is snapshotted to version history first.
Body (JSON):
- title (string) — 1–200 characters.
- markdown (string) — Replaces the body. Same limits as POST.
- html (string) — Replaces the body.
- content (object) — Replaces the body.
- recipients ({id?, name, email, role?, routingOrder?, accessCode?}[]) — Replaces the signing list, up to 30. Send back a recipient's `id` — or their unchanged address — to correct a name or email in place: their id is what the signature fields in the body are bound to, and a recipient who arrives without one is treated as somebody new, with nothing yet to sign.
- folderId (string | null) — A workspace folder, or null to take it out of one.
- inLibrary (boolean) — Add it to or remove it from the curated library.
- versionLabel (string) — Names the snapshot taken before the body is replaced. Up to 120 characters.
Returns: { data: Document, warnings: string[] } — the document in the shape GET /documents/:id returns
409: A new body for a document out for signature. Void the envelope first.
422: A body for an uploaded file, which is replaced with POST /documents/:id/file instead; or a folder that is not in your workspace.
### DELETE /api/v1/documents/:id
Scope: documents:write
Move a document to the trash, where it can be restored.
Query:
- purge (boolean) — `true` deletes it outright, and only from the trash.
Returns: 204
409: It is out for signature; or a purge was asked for on a document that is not in the trash, or that has been sent for signature and is kept as part of that record.
### POST /api/v1/documents/:id/restore
Scope: documents:write
Bring a document back from the trash.
Returns: { data: Document }
409: That document is not in the trash.
422: The plan's document ceiling has been reached; trashed documents do not count toward it.
### GET /api/v1/documents/:id/file
Scope: documents:read
The file under an uploaded document: the current source PDF, or a shared file (`kind: file`) in its own type.
Returns: The file bytes, with the stored content type and filename.
404: An authored document, which has no source file — export it instead.
### POST /api/v1/documents/:id/file
Scope: documents:write
Replace the file under an uploaded document. It keeps its id and every link to it; the outgoing file becomes a version.
Body (JSON):
- file (multipart file field) — Or the raw PDF as the body with an `x-filename` header. 25 MB maximum.
Returns: { data: { documentId, previousVersionId, pageCount, previousPageCount, hasTextLayer, droppedFields } } — `previousVersionId` holds the outgoing file; `droppedFields` counts field placements on pages the new file no longer has
400: Not a PDF, unreadable, or has no pages.
409: The document is out for signature, or is not an uploaded PDF.
422: Larger than 25 MB.
503: The file could not be stored. Nothing changed; safe to retry.
### GET /api/v1/documents/:id/export
Scope: documents:read
The document as a file, typeset the way the app exports it.
Query:
- format (string) — `pdf` (default), `docx` or `md`. Anything else is a 400.
Returns: The file bytes.
409: An uploaded PDF can only be exported as PDF.
### GET /api/v1/documents/:id/versions
Scope: documents:read
History, newest first: the restore points somebody named and the snapshots the document took of itself.
Query:
- limit (integer) — Up to 100. Defaults to 50.
Returns: { data: Version[] } — id, documentId, title, kind, pageCount, label, automatic, createdBy, createdAt. `automatic` is true for the snapshots the document took of itself, which have no label.
### POST /api/v1/documents/:id/versions
Scope: documents:write
Mark a restore point at the document as it stands.
Body (JSON):
- label (string) — What this point is, up to 120 characters. Defaults to naming the API.
Returns: 201 { data: Version }
### GET /api/v1/documents/:id/versions/:versionId
Scope: documents:read
One version.
Query:
- include (string) — `content` adds the body it preserved.
Returns: { data: Version }
### POST /api/v1/documents/:id/versions/:versionId
Scope: documents:write
Put a version back. What it replaces is snapshotted first, so a restore is itself undoable.
Returns: { data: Document }
409: The document is out for signature.
422: The file stored with that version could not be read, so restoring it would leave the document half-changed.
### DELETE /api/v1/documents/:id/versions/:versionId
Scope: documents:write
Drop a snapshot. The document is untouched.
Returns: 204
409: A tracked link is pinned to this version; `details.links` names them. Re-point them first — an unpinned link quietly starts showing the live document.
### POST /api/v1/documents/:id/signature-blocks
Scope: documents:write
Append somewhere to sign — name, signature and date, bound to one recipient. The step between writing a document through the API and being able to send it, because an envelope refuses to go to a recipient with nothing to complete.
Body (JSON):
- recipientId (string) — A recipient on the document. Optional when there is only one.
- email (string) — Identify that recipient by address instead.
- heading (string) — The label above the block, usually the party's role in the agreement. Up to 120 characters; defaults to the recipient's name.
Returns: 201 { data: Document, addedFor: { recipientId, name, email }, totalFields } — the body before the block is snapshotted to version history first; `totalFields` counts every field now in the body, across all recipients
409: The document is out for signature.
422: No recipients yet, a recipient who is not on the document, several recipients and none named, or an uploaded PDF — whose fields are placed by page coordinate in the editor.
### GET /api/v1/documents/:id/comments
Scope: documents:read
The comment thread, oldest first.
Query:
- resolved (boolean) — `false` for what is still open.
Returns: { data: Comment[] } — id, documentId, parentId, authorName, authorEmail, body, quote, resolved, createdAt. A reply carries the `parentId` of the comment that opened its thread.
### POST /api/v1/documents/:id/comments
Scope: documents:write
Leave a comment. The owner, the thread and anyone @-mentioned are emailed, as they are from the editor.
Body (JSON):
- body (string) required — Up to 10,000 characters. `@name` mentions are notified.
- parentId (string) — Reply into an existing thread: the id of the comment that opened it. Threads are one level deep.
- quote (string) — The passage being commented on, up to 2,000 characters.
Returns: 201 { data: Comment, notified: string[] } — `notified` is the addresses emailed
422: `parentId` is not a comment on this document, or is itself a reply.
### PATCH /api/v1/documents/:id/comments/:commentId
Scope: documents:write
Resolve or reopen any comment on the document, or correct the wording of one of your own.
Body (JSON):
- resolved (boolean)
- body (string) — Only on a comment this account wrote — a collaborator's words are theirs to change.
Returns: { data: Comment }
403: `body` was sent for a comment somebody else wrote. Nothing is changed, `resolved` included.
422: `resolved` was sent for a reply. Threads are resolved on their first comment.
### DELETE /api/v1/documents/:id/comments/:commentId
Scope: documents:write
Remove one of your own comments and its replies.
Returns: 204
403: The comment was written by somebody else. Reply to it or resolve the thread instead.
### GET /api/v1/folders
Scope: documents:read
The workspace's document folders, in the order the dashboard shows them — where the `folderId` a document is filed under comes from. Folders nest; the whole tree comes back in one call, so build it from `parentId` rather than asking per level.
Returns: { data: Folder[] } — id, name, parentId (null at the top level), position, documentCount (live documents filed directly in it, not in its subfolders), createdAt, updatedAt
### POST /api/v1/folders
Scope: documents:write
A folder, after the existing ones in the same place. File documents into it with `folderId` on POST or PATCH /documents.
Body (JSON):
- name (string) required — 1–120 characters.
- parentId (string | null) — Nest it inside one of your folders. Omit for the top level.
Returns: 201 { data: Folder }
404: No such parent folder in your workspace.
422: That would nest the folder more than 8 levels deep.
### PATCH /api/v1/folders/:id
Scope: documents:write
Rename a folder, reorder it among its siblings, or move it — with everything under it — into another folder.
Body (JSON):
- name (string) — 1–120 characters.
- position (integer 0–10,000) — Orders it among the folders sharing its parent.
- parentId (string | null) — Move it inside another folder, or null for the top level. Omit to leave it where it is.
Returns: { data: Folder }
404: No such folder, or no such parent folder in your workspace.
422: A move into the folder itself or into one of its own subfolders, or one that would take the branch more than 8 levels deep.
### DELETE /api/v1/folders/:id
Scope: documents:write
Remove a folder. Its documents and its subfolders move up a level, into whatever it was itself inside, rather than going with it.
Returns: 204
## Templates
A document saved for reuse. Recipient slots and the fields bound to them are kept; the addresses and access codes are stripped, so a template can never quietly send to whoever signed last time.
### GET /api/v1/templates
Scope: documents:read
Saved templates, most recently changed first.
Query:
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: Template[], nextCursor } — id, name, description, kind, pageCount, recipients: [{ id, name, role, routingOrder }], useCount, createdAt, updatedAt
### GET /api/v1/templates/:id
Scope: documents:read
One template.
Query:
- include (string) — `content` adds the body.
Returns: { data: Template }
### POST /api/v1/templates
Scope: documents:write
Save one of your documents as a template.
Body (JSON):
- documentId (string) required — The document to copy.
- name (string) — 1–200 characters. Defaults to the document's title plus “template”.
- description (string) — Up to 2,000 characters.
Returns: 201 { data: Template }
### PATCH /api/v1/templates/:id
Scope: documents:write
Rename or re-describe a template. Its body is fixed at save time.
Body (JSON):
- name (string) — 1–200 characters.
- description (string) — Up to 2,000 characters.
Returns: { data: Template }
### DELETE /api/v1/templates/:id
Scope: documents:write
Delete a template. Documents made from it are untouched.
Returns: 204
## Envelopes
A document out for signature. Sending freezes a snapshot, so later edits cannot change what somebody agreed to, and the completed PDF carries a certificate with the whole audit trail.
### GET /api/v1/envelopes
Scope: envelopes:read
Envelopes you sent, newest first, each with its parties.
Query:
- status (string) — `sent`, `completed`, `declined`, `voided` or `expired`. Anything else is a 400.
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: Envelope[], nextCursor } — id, documentId, title, kind, pageCount, subject, message, senderName, senderEmail, status, sequential, contentHash, completionHash, expiresAt, reminderEveryDays, lastReminderAt, sentAt, completedAt, voidedAt, voidReason, recipients: [{ id, name, email, role, routingOrder, status, viewedAt, signedAt, declinedAt, declineReason, notifiedAt, deliveryFailedAt, deliveryError, authMethod, authenticatedAt, delegatedToEmail, delegatedFromId }]. `deliveryFailedAt` is set when the signing email bounced; `authMethod` is `email` or `email+access_code`, and the code itself is never returned.
### POST /api/v1/envelopes
Scope: envelopes:write
Send a prepared document for signature. Recipients and field placement come from the document, because that is where they are authored.
Body (JSON):
- documentId (string) required — A draft with at least one signer, each with a name, an address and at least one field to complete — see POST /documents/:id/signature-blocks.
- subject (string) — The email subject, up to 300 characters. Defaults to “Please sign: <title>”.
- message (string) — A note inside the email, up to 2,000 characters.
- sequential (boolean) — True routes signers one after another; false sends to everybody at once.
- expiresAt (ISO 8601 datetime) — After this the envelope stops accepting signatures.
- reminderEveryDays (integer 1–30) — Nudge whoever is holding it up on this cadence.
Returns: 201 { data: Envelope, delivery: [{ email, delivered, reason }] } — check `delivery`: the envelope is real and its links work even when a notification bounced.
409: That document is already out for signature — including when a concurrent send claimed it first, so a retried request cannot mail one agreement twice.
404: No such document.
422: Not ready to send: no signers, a recipient missing a name or address, a signer with no field to complete, a field bound to a recipient no longer on the list, unresolved suggestions in the body, a placeholder left in it (`[COMPANY NAME]`, “TBD”) or a signature line with no field — or a shared file, which cannot be signed. The message names which.
402: The plan's sends for this month are used and the credit balance will not cover another. Nothing was charged; top up on the billing page or upgrade the plan.
503: Billing did not answer, so nothing was sent or charged. Safe to retry.
### GET /api/v1/envelopes/:id
Scope: envelopes:read
Full status, with every party and every field.
Query:
- include (string) — `signingUrls` adds each outstanding recipient's signing link. That link is the capability to sign, so it needs the envelopes:write scope.
Returns: { data: Envelope & { recipients[], fields: [{ id, recipientId, type, label, required, completedAt }] } } — with `signingUrls`, each recipient also carries `signingUrl`, null for anyone who has signed, declined or passed it on
403: Signing links were asked for by a key that cannot send.
### POST /api/v1/envelopes/:id/remind
Scope: envelopes:write
Nudge whoever the envelope is waiting on — on a sequential envelope that is one person, not everybody unsigned.
Returns: { data: Envelope, reminded: number }
409: The envelope has ended, or nobody is currently waiting.
### POST /api/v1/envelopes/:id/void
Scope: envelopes:write
Cancel an envelope in flight. Everyone outstanding is told the reason and the document returns to draft.
Body (JSON):
- reason (string) required — Up to 500 characters. Sent to the recipients and written to the audit trail.
Returns: { data: Envelope }
409: The envelope has already ended.
### POST /api/v1/envelopes/:id/recipients/:recipientId/reassign
Scope: envelopes:write
Move a signature to somebody else. The fields follow the person, and the original stays on the envelope as `delegated`.
Body (JSON):
- name (string) required
- email (string) required
- reason (string) — Up to 500 characters. Recorded in the trail.
Returns: { data: Envelope, replacementId } — `replacementId` is the new recipient's id
409: The envelope has already ended.
422: That recipient declined, has already signed, already passed it on, or the address is unchanged.
### GET /api/v1/envelopes/:id/pdf
Scope: envelopes:read
The executed document with every signature and the certificate of completion.
Returns: The PDF bytes. For a completed envelope, the copy archived at completion rather than a fresh render.
422: The PDF could not be built.
### GET /api/v1/envelopes/:id/certificate
Scope: envelopes:read
The same evidence as data: signers, consent records, authentication, the audit trail and the integrity digests.
Returns: { data: Certificate } — the digests are recomputed from the stored rows and reported as `documentHashVerified` / `completionHashVerified`, not merely echoed back.
### GET /api/v1/envelopes/:id/attachments
Scope: envelopes:read
Files a signer returned, as metadata.
Returns: { data: Attachment[] } — id, envelopeId, recipientId, fieldId, filename, contentType, byteSize, sha256, uploadedAt, newest first. `sha256` is the digest of the bytes as received.
### GET /api/v1/envelopes/:id/attachments/:attachmentId
Scope: envelopes:read
The bytes of one returned file, streamed through rather than exposed as a storage URL.
Returns: The file bytes.
## Share links
A tracked link to one document or one room, with its own gates. Every gate the share dialog offers is here under the same name the response returns it with, so you can read a link, change one field and send it back.
### GET /api/v1/links
Scope: links:read
Tracked links, newest first, each with its URL on your link origin.
Query:
- documentId (string) — Links to one document.
- dataRoomId (string) — Links to one room.
- active (boolean) — `true` for links that still answer.
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: Link[], nextCursor }
### POST /api/v1/links
Scope: links:write
Publish a link to one document or one room.
Body (JSON):
- documentId (string) — Exactly one of this and `dataRoomId`.
- dataRoomId (string) — Exactly one of this and `documentId`.
- name (string) — How the link is labelled in analytics. Defaults to “Shared link”.
- active (boolean) — Defaults to true.
- isPublic (boolean) — Publish the document: anyone with the address reads it immediately, and search engines are asked to index the page. Forces `requireEmail`, `requireEmailVerification`, `password` and `requireNda` off, whatever else the same request sets. Defaults to false, so a link is private unless you say otherwise.
- requireEmail (boolean) — Ask a visitor who they are. Defaults to true, and ignored when `isPublic` is set.
- requireEmailVerification (boolean) — Send a six-digit code to that address and check it. Implies `requireEmail`.
- password (string | null) — Protect the link. Null removes it.
- expiresAt (ISO 8601 datetime | null)
- maxViews (integer | null) — Stop answering after this many visits.
- pinnedVersionId (string | null) — Serve one saved version rather than the live document.
- allowDownload (boolean) — Defaults to false.
- watermark (boolean) — Defaults to true.
- watermarkConfig (object | null) — `{ text, position, opacity, rotation, fontSize, color }`, all optional and merged over the current values. `text` takes the placeholders {email}, {date}, {time}, {ip} and {link}; `position` is tiled, center or footer; `opacity` 1–100; `rotation` -90–90; `fontSize` 8–96; `color` a six-digit hex. Null clears it back to the default.
- requireNda (boolean) — Ask the visitor to accept terms before they can read.
- ndaText (string) — Terms written on the link itself.
- agreementId (string | null) — Terms from your agreements library instead. Editing that agreement re-asks everyone who accepted the old wording.
- allowedEmails (string[]) — Only these addresses — or anyone at `allowedDomains`, when both are set. Up to 200 entries.
- allowedDomains (string[]) — Only addresses at these domains, matched exactly: `acme.com` does not admit `eu.acme.com`. Up to 200.
- blockedEmails (string[]) — Never these. An entry without an `@` in the middle — `rival.com` or `@rival.com` — is a domain and blocks everybody at it. Up to 200.
- notifyOnView (boolean) — Email you when somebody opens it. Defaults to true.
- screenshotProtection (boolean) — Deter casual copying: printing is blocked and the page hides when the window loses focus. Defaults to false.
- allowAssistant (boolean) — Let the reader ask questions about the document. Defaults to false.
- allowAudio (boolean) — Let the reader have the document read aloud, choosing a voice and a speed. Defaults to false.
- allowFeedback (boolean) — Show a reaction bar under the document. Defaults to false.
- bulkDownloadOtp (boolean) — Room links only: make a visitor confirm an emailed code before a zip of everything. Defaults to true.
- tags (string[]) — Your own labels for filtering links: up to 20, each one word of up to 32 characters, stored lowercased.
- captureFields ({label, type?, required?, options?, id?}[]) — Extra questions asked with the email, up to 8. `type` is text, select or checkbox and defaults to text; a select needs `options`. `id` is minted from the label when you omit it, and is the key the answers come back under on a view.
- ogTitle (string | null) — What a chat app or social card shows when the link is pasted. Defaults to the document or room name.
- ogDescription (string | null) — The line under that title.
- ogImageUrl (string | null) — The card's picture, an http or https address. Defaults to the XDocs card. 1200×630 previews best.
- ogFaviconUrl (string | null) — The icon in the browser tab on the viewer page.
Returns: 201 { data: Link } — including `url`, `slug` and `watermarkConfig` as resolved
422: A pinned version or agreement that is not yours, or the plan's link ceiling has been reached.
### GET /api/v1/links/:id
Scope: links:read
One link and its gates. The password is reported as `hasPassword`, never returned.
Returns: { data: Link } — id, name, slug, url, documentId, dataRoomId, groupId, viewCount, hasPassword, createdAt, updatedAt, and every setting POST accepts under the same name. `ndaText` is null while an `agreementId` supplies the terms. `groupId` marks a room link made in the app for one viewer group: it admits only that group's members and shows only the files the group was given, whatever `allowDownload` says.
### PATCH /api/v1/links/:id
Scope: links:write
Change any setting; omitted fields are kept. Tightening a gate re-gates visitors who already cleared it — a new password or new terms are asked for again, and the address lists are re-checked on every visit, so a newly blocked address is turned away on its next one.
Body (JSON):
- … (same as POST) — Every setting POST accepts, all optional. `documentId` and `dataRoomId` are not among them: a link points at what it was made for.
Returns: { data: Link }
422: A pinned version that is not this document's (or any pin on a room link), an agreement that is not in your library, or an `ogImageUrl` / `ogFaviconUrl` that is not http or https. A viewer-group link cannot be made public, or switched back on once its group has been deleted.
### DELETE /api/v1/links/:id
Scope: links:write
The link stops answering. Its visits are kept, because they are evidence about who read what.
Returns: 204
## Data rooms
Many documents behind one set of gates, in folders, with reading attributed per file. A frozen room's contents are locked while its links keep serving them; an archived room is retired, and every link into it answers with a closed page until it is restored. Viewer groups, which narrow a room link to some of its files, are set up in the app.
### GET /api/v1/rooms
Scope: rooms:read
Your data rooms, most recently changed first.
Query:
- archived (boolean) — `false` for the rooms the app's room list shows, `true` for the archived ones. Omitted, both.
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: Room[], nextCursor } — id, name, description, frozenAt, archivedAt, createdAt, updatedAt
### POST /api/v1/rooms
Scope: rooms:write
An empty room; file documents into it next.
Body (JSON):
- name (string) required — 1–200 characters.
- description (string) — Up to 2,000 characters.
Returns: 201 { data: Room & { folders: [], documents: [] } }
422: The plan's room ceiling has been reached.
### GET /api/v1/rooms/:id
Scope: rooms:read
The room with its state, folders and filed documents, in the order an ordinary room link shows them.
Returns: { data: Room & { folders: Folder[], documents: [{ documentId, folderId, position, title, kind, addedAt }] } } — documents in the trash are left out
### PATCH /api/v1/rooms/:id
Scope: rooms:write
Rename or re-describe a room, freeze or unfreeze it, archive or restore it. None of these changes what is filed, so a frozen room accepts them.
Body (JSON):
- name (string) — 1–200 characters.
- description (string) — Up to 2,000 characters.
- frozen (boolean) — `true` locks the contents — nothing filed, moved or removed — while links keep serving exactly what is there.
- archived (boolean) — `true` retires the room: every link into it answers with a closed page. `false` puts it all back.
Returns: { data: Room & { folders[], documents[] } }
### DELETE /api/v1/rooms/:id
Scope: rooms:write
To the trash. Links into the room stop answering at once; the documents themselves are untouched.
Returns: 204
### GET /api/v1/rooms/:id/documents
Scope: rooms:read
What is filed in the room, and where.
Returns: { data: [{ documentId, folderId, position, title, kind, addedAt }] }
### POST /api/v1/rooms/:id/documents
Scope: rooms:write
File one of your documents. Idempotent: filing one already there moves it, which is what a sync wants.
Body (JSON):
- documentId (string) required — One of your live documents.
- folderId (string | null) — A folder in this room, or null for the top level.
Returns: 201 { data: RoomDocument[] } — everything now filed in the room, not only this document
404: No such document, or no such folder in this room.
409: The room is frozen. Unfreeze it with PATCH /rooms/:id to change its contents.
### PATCH /api/v1/rooms/:id/documents/:documentId
Scope: rooms:write
Move a filed document between folders or along the order.
Body (JSON):
- folderId (string | null) — A folder in this room, or null for the top level.
- position (integer 0–10,000)
Returns: { data: RoomDocument[] }
404: That document is not in this room, or no such folder in it.
409: The room is frozen. Unfreeze it with PATCH /rooms/:id to change its contents.
### DELETE /api/v1/rooms/:id/documents/:documentId
Scope: rooms:write
Take a document out of the room. The document itself stays.
Returns: 204
409: The room is frozen. Unfreeze it with PATCH /rooms/:id to change its contents.
### GET /api/v1/rooms/:id/folders
Scope: rooms:read
The room's folders, in display order.
Returns: { data: Folder[] } — id, dataRoomId, name, position, createdAt
### POST /api/v1/rooms/:id/folders
Scope: rooms:write
A folder, appended after the existing ones.
Body (JSON):
- name (string) required — 1–200 characters.
Returns: 201 { data: Folder }
409: The room is frozen. Unfreeze it with PATCH /rooms/:id to change its contents.
### PATCH /api/v1/rooms/:id/folders/:folderId
Scope: rooms:write
Rename or reorder a folder.
Body (JSON):
- name (string) — 1–200 characters.
- position (integer 0–10,000)
Returns: { data: Folder }
409: The room is frozen. Unfreeze it with PATCH /rooms/:id to change its contents.
### DELETE /api/v1/rooms/:id/folders/:folderId
Scope: rooms:write
Remove a folder. Its documents return to the top level rather than leaving the room.
Returns: 204
409: The room is frozen. Unfreeze it with PATCH /rooms/:id to change its contents.
## Agreements
Confidentiality terms written once and picked by name on any link. Editing one re-asks everybody who accepted the old wording, while their record keeps the text they actually agreed to.
### GET /api/v1/agreements
Scope: links:read
The library, most recently edited first, with how much each one is used.
Returns: { data: Agreement[] } — id, name, body, requireName, linkCount, acceptedCount, createdAt, updatedAt
### GET /api/v1/agreements/:id
Scope: links:read
One agreement with its usage counts.
Returns: { data: Agreement }
### POST /api/v1/agreements
Scope: links:write
Write terms once; pick them by id on any link.
Body (JSON):
- name (string) required — 1–120 characters. How it is picked on a link.
- body (string) required — 20–20,000 characters. The exact text a visitor is asked to accept.
- requireName (boolean) — Also ask the visitor to type their name. Defaults to false.
Returns: 201 { data: Agreement }
### PATCH /api/v1/agreements/:id
Scope: links:write
Change the wording. New wording is a new agreement for everyone who accepted the old one.
Body (JSON):
- name (string) — 1–120 characters. Renaming re-asks nobody.
- body (string) — 20–20,000 characters.
- requireName (boolean)
Returns: { data: Agreement, regatedLinks: number } — `regatedLinks` is how many links now ask again, and is 0 unless the wording itself changed
### DELETE /api/v1/agreements/:id
Scope: links:write
Remove from the library. Links using it keep the gate, with the text copied onto them, so deleting never quietly opens a document; link presets that named it keep its words the same way.
Returns: 204
## Views and visitors
Reading measured section by section. Time accrues only while a section is on screen and the tab is active, so these are attention numbers rather than tab-left-open numbers.
### GET /api/v1/views
Scope: views:read
Visits, newest first. A visit counts once every gate was cleared.
Query:
- documentId (string)
- dataRoomId (string)
- shareLinkId (string)
- email (string)
- since (ISO 8601 datetime) — Visits started at or after this.
- includeUnopened (boolean) — `true` also lists visits that never cleared the gates.
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: View[], nextCursor } — id, shareLinkId, documentId, dataRoomId, viewerEmail, emailVerified, ndaAcceptedAt, ndaText (the exact terms accepted, whatever the link says now), ndaSignedName, openedAt (null until every gate was cleared), lastOpenedAt, startedAt, lastSeenAt, totalMs (active reading, not wall clock), opens (separate sittings), completion, downloadedAt, captureAnswers (keyed by the link's capture field `id`), country, city, referrer, userAgent, ipAddress
400: `since` is not a timestamp.
### GET /api/v1/views/:id
Scope: views:read
One visit with its per-section reading — per page for a PDF.
Returns: { data: View & { sections: [{ documentId, index, heading, visibleMs }] } } — a room visit carries several documents' sections, told apart by `documentId`
### GET /api/v1/visitors
Scope: views:read
Visits grouped by the person who made them: not “opened nine times” but “this person has read four documents for an hour”.
Query:
- documentId (string)
- dataRoomId (string)
- shareLinkId (string)
- email (string) — One visitor, by the address they gave.
- since (ISO 8601 datetime)
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: Visitor[], nextCursor } — email, views, documents, totalMs, avgCompletion, firstSeenAt, lastSeenAt, verified, acceptedNda, downloaded. Only visits that captured an address and cleared the gates are counted.
400: `since` is not a timestamp.
### GET /api/v1/visitors/:email
Scope: views:read
One person, with their most recent visits.
Returns: { data: Visitor & { views: View[] } } — the latest 100 visits
400: The path is not a URL-encoded email address.
404: No visits from that address.
### GET /api/v1/visits/export
Scope: documents:read
Every counted visit as CSV, one row per visit per document read — the file the settings page offers.
Query:
- from (ISO 8601 date or datetime) — Visits from this moment.
- to (ISO 8601 date or datetime) — Visits up to this moment. A bare date covers the whole of that day.
Returns: A CSV file.
## Analytics
The headline numbers behind the analytics page, and the attention profile of a single document.
### GET /api/v1/analytics/summary
Scope: views:read
Views against the previous period, visitors, reading time, completion, the top documents, rooms, links and visitors, devices, and a per-day series.
Query:
- days (integer) — 7, 30 or 90. Defaults to 30. Anything else is a 400.
Returns: { data: { days, since, current, previous, series[], topDocuments[], topRooms[], topLinks[], topVisitors[], recent[], devices, anonymousShare } } — `series` has one entry per day, oldest first; `anonymousShare` is the share of views (0–100) with no address captured
### GET /api/v1/analytics/documents/:id
Scope: views:read
Where readers spent their attention in one document, section by section — per page for a PDF — including visits that arrived through a room.
Query:
- shareLinkId (string) — Only visits through one link.
Returns: { data: { documentId, title, kind, unit, views, measuredViews, totalMs, sections: [{ index, heading, words, seen, readers, totalMs, medianMs, msPerHundredWords, friction, stoppedHere, reached, reachedPct }] } } — `unit` is `section` or `page`; `seen` counts visits that dwelt on a section at all and `readers` those that read it; `friction` is pace against the document's own median, where 1 is typical
## Viewer questions
What readers asked from inside a shared link, and your answers, which are emailed back to them.
### GET /api/v1/questions
Scope: views:read
Question threads, those waiting on you first. The two hundred most recently opened; a specific thread is always reachable by id.
Query:
- awaiting (boolean) — `true` for threads where the reader spoke last.
- documentId (string)
- dataRoomId (string)
- shareLinkId (string)
- email (string)
Returns: { data: Thread[] } — id, documentId, dataRoomId, shareLinkId, linkName, about (the document or room title), askedByEmail, awaitingOwner, createdAt, lastMessageAt, messages: [{ id, author, body, createdAt, delivered }], opening question first
### GET /api/v1/questions/:id
Scope: views:read
One thread, every turn in order.
Returns: { data: Thread }
### POST /api/v1/questions/:id/reply
Scope: links:write
Answer in the thread. Emailed to the asker when the link captured an address; `delivered` says whether it went.
Body (JSON):
- body (string) required — Up to 5,000 characters.
Returns: { data: Thread, delivered: boolean } — the reply is kept in the thread whether or not the email went
422: The reply could not be recorded; the message says why.
## File requests
Ask named people for named documents. Each assignee gets their own upload link, and whatever arrives becomes a document in the workspace, filed where the request says.
### GET /api/v1/requests
Scope: requests:read
File requests, most recently changed first, with progress counts.
Returns: { data: Request[] } — id, slug, url, title, description, dataRoomId, folderId, requireEmail, dueAt, remindEveryDays, notifyOnUpload, active, closedAt, createdAt, updatedAt, counts{items, assignees, completed, uploads}. `url` is the open upload page; each assignee is mailed their own.
### GET /api/v1/requests/:id
Scope: requests:read
The request with its items, assignees and what has arrived.
Returns: { data: Request & { items: [{ id, name, description, required, position }], assignees: Assignee[], uploads: [{ id, itemId, assigneeId, uploaderEmail, uploaderName, filename, contentType, byteSize, sha256, documentId, uploadedAt }] } } — an upload is read as the document named by `documentId`; assignee tokens and storage URLs are never included
### POST /api/v1/requests
Scope: requests:write
Ask for files. Assignees are mailed their own link at once.
Body (JSON):
- title (string) required — 1–200 characters.
- description (string) — Up to 5,000 characters.
- items ({name, description?, required?}[]) — What is wanted, up to 50. `required` defaults to true.
- dataRoomId (string | null) — Room uploads are filed into.
- folderId (string | null) — A folder in that room. Needs `dataRoomId`.
- dueAt (ISO 8601 datetime | null)
- remindEveryDays (integer 1–60 | null) — Reminder cadence for whoever has not finished.
- requireEmail (boolean) — Ask an anonymous uploader who they are. Defaults to true.
- notifyOnUpload (boolean) — Email you when something arrives. Defaults to true.
- assignees ({email, name?}[]) — Up to 50, each mailed their own link.
Returns: 201 { data: Request & { items[], assignees[], uploads[] } }
404: No such room, or no such folder in it.
422: A `folderId` without a `dataRoomId`.
### PATCH /api/v1/requests/:id
Scope: requests:write
Change any setting. `active: false` closes it, and the link says so instead of accepting files.
Body (JSON):
- … (same as POST) — `title`, `description`, `dueAt`, `remindEveryDays`, `requireEmail`, `notifyOnUpload` and `active`, all optional. The room, folder, items and assignees are fixed once a request exists.
Returns: { data: Request }
### DELETE /api/v1/requests/:id
Scope: requests:write
Delete the request. Uploads stay: they are documents in the workspace now.
Returns: 204
### POST /api/v1/requests/:id/assignees
Scope: requests:write
Add people and mail each their link. Idempotent per address: somebody already on the request has their name updated and is mailed again rather than added twice.
Body (JSON):
- assignees ({email, name?}[]) required — 1–50 people.
Returns: 201 { data: Assignee[] } — every assignee on the request: id, email, name, delivery, deliveryError, sentAt, remindedAt, reminderCount, uploadedAt, completedAt, createdAt
### DELETE /api/v1/requests/:id/assignees/:assigneeId
Scope: requests:write
Remove somebody. Their link stops working; what they already sent stays. Idempotent.
Returns: 204
### POST /api/v1/requests/:id/assignees/:assigneeId/resend
Scope: requests:write
Mail their link again — a fresh invitation if the first never arrived, otherwise a reminder.
Returns: { data: Assignee, delivered: boolean }
404: No such assignee on this request.
## Webhooks
Subscriptions an integration creates and removes on its own, one per thing it wants to hear about. This is the shape Zapier, Make and n8n expect, and a subscription is the same endpoint row the developer settings page shows with its delivery log — so these calls see and change the workspace's endpoints wherever they were made.
### GET /api/v1/hooks
Scope: documents:read
Every webhook endpoint in the workspace, newest first — including those added on the settings page — and the catalogue of events.
Returns: { data: Hook[], events: [{ name, description }] } — `events` includes `ping`, the test event
### POST /api/v1/hooks
Scope: documents:write
Subscribe a URL. Re-subscribing the same URL to the same events returns the existing row rather than a duplicate, because platforms retry.
Body (JSON):
- url (string) required — Must be https.
- events (string[]) — Event names, up to 50. Empty, with no `event` either, means every event.
- event (string) — A single event, for platforms that subscribe one at a time. Merged with `events`.
Returns: 201 { data: Hook & { secret } } — the signing secret. An identical re-subscribe answers 200 with the existing row and its secret, and switches it back on if it had been switched off.
422: Unknown event names; `details.known` lists the valid ones.
### GET /api/v1/hooks/:id
Scope: documents:read
One subscription, with its recent health.
Returns: { data: Hook } — id, url, events, active, failureCount, lastStatus, lastAttemptAt, createdAt. `failureCount` is consecutive failed deliveries; at 10 the endpoint is switched off.
### PATCH /api/v1/hooks/:id
Scope: documents:write
Change the URL or events, or switch it on and off. Switching it on resets the failure count.
Body (JSON):
- url (string) — Must be https.
- events (string[]) — Replaces the list. Empty means every event.
- active (boolean)
Returns: { data: Hook }
422: Unknown event names; `details.known` lists the valid ones.
### DELETE /api/v1/hooks/:id
Scope: documents:write
Unsubscribe. Idempotent: a second call is a 204 too.
Returns: 204
### POST /api/v1/hooks/:id/ping
Scope: documents:write
Send a `ping` delivery now, whatever the endpoint subscribes to and even if it is switched off, and report how it answered.
Returns: { data: Delivery & { payload } } — status, responseStatus, attempts, error
422: The test delivery could not be recorded.
### POST /api/v1/hooks/:id/rotate
Scope: documents:write
Replace the signing secret. The new one is returned once.
Returns: { data: Hook & { secret } }
### GET /api/v1/hooks/:id/deliveries
Scope: documents:read
Every delivery attempt, newest first — which is how a webhook that “does not work” turns out to be one specific 401.
Query:
- event (string)
- status (string) — `pending`, `delivered` or `failed`. Anything else is a 400.
- include (string) — `payload` adds the body that was sent.
- limit (integer) — Rows per page, up to 100. Defaults to 50.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
Returns: { data: Delivery[], nextCursor } — id, endpointId, event, status, attempts, responseStatus, error, deliveredAt, createdAt, payload?
### GET /api/v1/hooks/samples
Scope: documents:read
Recent real payloads for one event, for a platform's “load sample” step. A synthetic one of the same shape when nothing has happened yet.
Query:
- event (string) — The event name.
Returns: { data: object[] } — up to three delivered bodies, each `{ id, event, createdAt, data }`
422: Unknown event name; `details.known` lists the valid ones.
## Audit log
Every recorded event across signing, sharing and documents. This is the evidence trail — what happened, to what, by whom, from where — as opposed to analytics, which is about attention.
### GET /api/v1/audit
Scope: audit:read
The trail, newest first.
Query:
- family (string) — `signing`, `sharing` or `documents`. Anything else is a 400.
- type (string) — One event type, e.g. `recipient.signed` — see GET /audit/types. An unknown one is a 400.
- q (string) — Matches the description, actor name or actor email.
- from (ISO 8601 datetime)
- to (ISO 8601 datetime)
- documentId (string)
- envelopeId (string)
- shareLinkId (string) — One link's history, including after the link was deleted.
- limit (integer) — Up to 500. Defaults to 100, or 500 with `format=csv`.
- cursor (string) — The previous page's `nextCursor`. Opaque — pass it back unchanged.
- format (string) — `csv` returns the same rows as a file.
Returns: { data: Event[], nextCursor } — id, type, family, description, actorName, actorEmail, envelopeId, documentId, recipientId, shareLinkId, subject{kind,id,title} (kind is envelope, document, room or link), ipAddress, userAgent, metadata, createdAt
400: An unknown family or type, a date that does not parse, or a cursor that is not valid.
### GET /api/v1/audit/types
Scope: audit:read
Every event type the log can hold, grouped by family, for building a filter.
Returns: { data: { families: [{ id, label }], types: [{ type, family }] } }
## Webhook events
Subscribe with POST /api/v1/hooks. Each delivery is a POST whose JSON body is `{ id, event, createdAt, data }`, carrying x-xdocs-event, x-xdocs-event-id, x-xdocs-timestamp and x-xdocs-signature — a hex HMAC-SHA256 of `{timestamp}.{body}` using the secret returned at subscribe. Verify the raw body before parsing it.
Answer with a 2xx within eight seconds. A delivery is tried up to three times with a short backoff; a 4xx other than 429 is a refusal and is not retried. After 10 consecutive failed deliveries the endpoint is switched off, and PATCH /hooks/:id with active: true turns it back on.
- envelope.sent — A document went out for signature.
- recipient.notified — A signing email left for a recipient.
- recipient.bounced — A signing email could not be delivered.
- recipient.viewed — A recipient opened their signing page.
- recipient.authenticated — A recipient passed the access code.
- recipient.signed — A recipient completed their fields.
- recipient.declined — A recipient declined to sign, with a reason.
- recipient.delegated — A recipient reassigned signing to someone else.
- recipient.attached — A recipient returned a file.
- envelope.reminded — Reminders went to whoever is holding things up.
- envelope.completed — Every signer has signed; the PDF is final.
- envelope.voided — The sender cancelled the envelope.
- envelope.expired — The envelope passed its expiry unsigned.
- consent.accepted — A signer agreed to sign electronically.
- consent.withdrawn — A signer withdrew that consent.
- document.created — A new document was created.
- document.edited — A document's body changed.
- document.redacted — Text was permanently removed from a document.
- link.created — A tracked link was published.
- link.updated — A link's gates or settings changed.
- link.enabled — A link was switched back on.
- link.disabled — A link was switched off.
- link.deleted — A link was removed.
- link.viewed — A visitor cleared every gate and opened the link.
- link.nda_accepted — A visitor accepted the link's confidentiality terms.
- link.downloaded — A visitor downloaded a PDF copy.
- link.bulk_downloaded — A visitor downloaded a room or folder as a zip.
- ping — A test delivery sent from the settings page.
There is also a command line — npx xdocs documents list — and a Zapier app, both thin clients over exactly these endpoints. Anything they can do, curl can do.
Build it against the same API the product runs on.