# 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: ”. - 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.